Compare commits
129 Commits
feature/IO
...
feature/IO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c68feef0b5 | ||
|
|
4a7bb07345 | ||
|
|
01fec9fa79 | ||
|
|
2f88d613c3 | ||
|
|
c9467b3982 | ||
|
|
ca1a456312 | ||
|
|
ca4c48bd5c | ||
|
|
e5fd5c8bcb | ||
|
|
46945a24a7 | ||
|
|
be746500a6 | ||
|
|
71c6d9fa94 | ||
|
|
c010665ea9 | ||
|
|
6d94ce7e5c | ||
|
|
d6fba12cd9 | ||
|
|
182a8d59ab | ||
|
|
f1847ef650 | ||
|
|
6ea1c291e6 | ||
|
|
05d5c96491 | ||
|
|
35a566cbe5 | ||
|
|
f12e40e4c6 | ||
|
|
bb4e671c83 | ||
|
|
d1637d2432 | ||
|
|
1c79628613 | ||
|
|
521a7084b7 | ||
|
|
77268d5f5b | ||
|
|
1b3abf17ec | ||
|
|
3cfd445894 | ||
|
|
b510eec9aa | ||
|
|
e5eac0933f | ||
|
|
a3c71fdfc0 | ||
|
|
78750d3d96 | ||
|
|
90edf94fee | ||
|
|
065fb72677 | ||
|
|
ddc6141480 | ||
|
|
7bc137fa79 | ||
|
|
dafe9de753 | ||
|
|
78a8474a24 | ||
|
|
123066f1cd | ||
|
|
a153cca3c0 | ||
|
|
35c7c32c8e | ||
|
|
6d6b64ebc3 | ||
|
|
c954695d3c | ||
|
|
338d8e2136 | ||
|
|
6674206b4f | ||
|
|
c46ad521d1 | ||
|
|
66e5bec4d8 | ||
|
|
0d3161ef84 | ||
|
|
1cd11bdc18 | ||
|
|
9cce2696e2 | ||
|
|
508d32d2d9 | ||
|
|
cccc307862 | ||
|
|
0772139a60 | ||
|
|
70028c8be6 | ||
|
|
3dc22bfdab | ||
|
|
f3ee421030 | ||
|
|
6b41d6f2a2 | ||
|
|
b8fed77f43 | ||
|
|
3f5614d77e | ||
|
|
6c5c4bd333 | ||
|
|
43c1eef70c | ||
|
|
b8d97d9821 | ||
|
|
6843441b17 | ||
|
|
409e04ed0e | ||
|
|
95c7872b34 | ||
|
|
91bf5c8d0f | ||
|
|
3660fb1b1b | ||
|
|
7573286163 | ||
|
|
34ab42c0ad | ||
|
|
a3c3f60d2a | ||
|
|
8147bc76fd | ||
|
|
9ef1022311 | ||
|
|
3c0e62ffac | ||
|
|
a9a0415501 | ||
|
|
fbaf47b89b | ||
|
|
907f291f90 | ||
|
|
abce19530f | ||
|
|
1fd9b68320 | ||
|
|
cff1afe605 | ||
|
|
b337309f94 | ||
|
|
77f041b0f1 | ||
|
|
68be8670b4 | ||
|
|
745c429f08 | ||
|
|
cc9e4740de | ||
|
|
06ebcbaa07 | ||
|
|
42427c4569 | ||
|
|
d5f921ed35 | ||
|
|
043471fdbc | ||
|
|
54850e8ee2 | ||
|
|
199ddc7d9e | ||
|
|
13a44b9a59 | ||
|
|
e3337bacea | ||
|
|
8e6c809fc6 | ||
|
|
41afedd02c | ||
|
|
29df829120 | ||
|
|
11e0c3e507 | ||
|
|
6c1c7c9c2c | ||
|
|
d2b6054e60 | ||
|
|
7004ed9880 | ||
|
|
36dfed80fb | ||
|
|
e3df22160b | ||
|
|
0ccfe6f3aa | ||
|
|
34d70f6a18 | ||
|
|
fc1bf213c7 | ||
|
|
4dc72986d0 | ||
|
|
f19b9cb8e1 | ||
|
|
c9ade68dc9 | ||
|
|
0d247a38d2 | ||
|
|
595ffa02ba | ||
|
|
d573335eb0 | ||
|
|
bdd5056c9a | ||
|
|
4f6db827e7 | ||
|
|
e50bbc3bcc | ||
|
|
15792cb0ef | ||
|
|
856a24d496 | ||
|
|
74d6bcc004 | ||
|
|
2af95de353 | ||
|
|
7ffb2c1aad | ||
|
|
cf3f94bf98 | ||
|
|
4899297539 | ||
|
|
0bd8e862e4 | ||
|
|
4afafef98f | ||
|
|
48a3220c49 | ||
|
|
7bc105ea46 | ||
|
|
ac4fcf1694 | ||
|
|
678a87f55d | ||
|
|
9e977d9a58 | ||
|
|
43f822e6ad | ||
|
|
7239698a21 | ||
|
|
5e308f13d3 |
@@ -0,0 +1,61 @@
|
||||
# PATCH /integrations/parts-management/job/:id/status
|
||||
|
||||
Update (patch) the status of a job created under parts management. This endpoint is only available
|
||||
for jobs whose parent bodyshop has an `external_shop_id` (i.e., is provisioned for parts
|
||||
management).
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
PATCH /integrations/parts-management/job/:id/status
|
||||
```
|
||||
|
||||
- `:id` is the UUID of the job to update.
|
||||
|
||||
## Request Headers
|
||||
|
||||
- `Authorization`: (if required by your integration middleware)
|
||||
- `Content-Type: application/json`
|
||||
|
||||
## Request Body
|
||||
|
||||
Send a JSON object with the following field:
|
||||
|
||||
- `status` (string, required): The new status for the job.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
PATCH /integrations/parts-management/job/123e4567-e89b-12d3-a456-426614174000/status
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "IN_PROGRESS"
|
||||
}
|
||||
```
|
||||
|
||||
## Success Response
|
||||
|
||||
- **200 OK**
|
||||
- Returns the updated job object with the new status.
|
||||
|
||||
```
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"status": "IN_PROGRESS",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
- **400 Bad Request**: Missing status field, or parent bodyshop does not have an `external_shop_id`.
|
||||
- **404 Not Found**: No job found with the given ID.
|
||||
- **500 Internal Server Error**: Unexpected error.
|
||||
|
||||
## Notes
|
||||
|
||||
- Only jobs whose parent bodyshop has an `external_shop_id` can be patched via this route.
|
||||
- Fields other than `status` will be ignored if included in the request body.
|
||||
- The route is protected by the same middleware as other parts management endpoints.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# PATCH /integrations/parts-management/provision/:id
|
||||
|
||||
Update (patch) select fields for a parts management bodyshop. Only available for shops that have an
|
||||
`external_shop_id` (i.e., are provisioned for parts management).
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
PATCH /integrations/parts-management/provision/:id
|
||||
```
|
||||
|
||||
- `:id` is the UUID of the bodyshop to update.
|
||||
|
||||
## Request Headers
|
||||
|
||||
- `Authorization`: (if required by your integration middleware)
|
||||
- `Content-Type: application/json`
|
||||
|
||||
## Request Body
|
||||
|
||||
Send a JSON object with one or more of the following fields to update:
|
||||
|
||||
- `shopname` (string)
|
||||
- `address1` (string)
|
||||
- `address2` (string, optional)
|
||||
- `city` (string)
|
||||
- `state` (string)
|
||||
- `zip_post` (string)
|
||||
- `country` (string)
|
||||
- `email` (string, shop's email, not user email)
|
||||
- `timezone` (string)
|
||||
- `phone` (string)
|
||||
- `logo_img_path` (object, e.g. `{ src, width, height, headerMargin }`)
|
||||
|
||||
Any fields not included in the request body will remain unchanged.
|
||||
|
||||
## Example Request
|
||||
|
||||
```
|
||||
PATCH /integrations/parts-management/provision/123e4567-e89b-12d3-a456-426614174000
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"shopname": "New Shop Name",
|
||||
"address1": "123 Main St",
|
||||
"city": "Springfield",
|
||||
"state": "IL",
|
||||
"zip_post": "62704",
|
||||
"country": "USA",
|
||||
"email": "shop@example.com",
|
||||
"timezone": "America/Chicago",
|
||||
"phone": "555-123-4567",
|
||||
"logo_img_path": {
|
||||
"src": "https://example.com/logo.png",
|
||||
"width": "200",
|
||||
"height": "100",
|
||||
"headerMargin": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Success Response
|
||||
|
||||
- **200 OK**
|
||||
- Returns the updated shop object with the patched fields.
|
||||
|
||||
```
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"shopname": "New Shop Name",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
- **400 Bad Request**: No valid fields provided, or shop does not have an `external_shop_id`.
|
||||
- **404 Not Found**: No shop found with the given ID.
|
||||
- **500 Internal Server Error**: Unexpected error.
|
||||
|
||||
## Notes
|
||||
|
||||
- Only shops with an `external_shop_id` can be patched via this route.
|
||||
- Fields not listed above will be ignored if included in the request body.
|
||||
- The route is protected by the same middleware as other parts management endpoints.
|
||||
|
||||
728
client/package-lock.json
generated
728
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,56 +8,56 @@
|
||||
"private": true,
|
||||
"proxy": "http://localhost:4000",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-browser": "^2.23.5",
|
||||
"@amplitude/analytics-browser": "^2.25.2",
|
||||
"@ant-design/pro-layout": "^7.22.6",
|
||||
"@apollo/client": "^3.13.9",
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@fingerprintjs/fingerprintjs": "^4.6.1",
|
||||
"@firebase/analytics": "^0.10.17",
|
||||
"@firebase/app": "^0.14.2",
|
||||
"@firebase/app": "^0.14.3",
|
||||
"@firebase/auth": "^1.10.8",
|
||||
"@firebase/firestore": "^4.9.1",
|
||||
"@firebase/firestore": "^4.9.2",
|
||||
"@firebase/messaging": "^0.12.22",
|
||||
"@jsreport/browser-client": "^3.1.0",
|
||||
"@reduxjs/toolkit": "^2.9.0",
|
||||
"@sentry/cli": "^2.53.0",
|
||||
"@sentry/cli": "^2.56.0",
|
||||
"@sentry/react": "^9.43.0",
|
||||
"@sentry/vite-plugin": "^4.3.0",
|
||||
"@splitsoftware/splitio-react": "^2.3.1",
|
||||
"@splitsoftware/splitio-react": "^2.5.0",
|
||||
"@tanem/react-nprogress": "^5.0.53",
|
||||
"antd": "^5.27.3",
|
||||
"antd": "^5.27.4",
|
||||
"apollo-link-logger": "^2.0.1",
|
||||
"apollo-link-sentry": "^4.4.0",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.11.0",
|
||||
"axios": "^1.12.2",
|
||||
"classnames": "^2.5.1",
|
||||
"css-box-model": "^1.2.1",
|
||||
"dayjs": "^1.11.18",
|
||||
"dayjs-business-days2": "^1.3.0",
|
||||
"dinero.js": "^1.9.1",
|
||||
"dotenv": "^17.2.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"env-cmd": "^10.1.0",
|
||||
"exifr": "^7.1.3",
|
||||
"graphql": "^16.11.0",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next": "^25.5.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"libphonenumber-js": "^1.12.15",
|
||||
"libphonenumber-js": "^1.12.23",
|
||||
"lightningcss": "^1.30.2",
|
||||
"logrocket": "^9.0.2",
|
||||
"markerjs2": "^2.32.6",
|
||||
"markerjs2": "^2.32.7",
|
||||
"memoize-one": "^6.0.0",
|
||||
"normalize-url": "^8.0.2",
|
||||
"normalize-url": "^8.1.0",
|
||||
"object-hash": "^3.0.0",
|
||||
"phone": "^3.1.67",
|
||||
"posthog-js": "^1.261.7",
|
||||
"posthog-js": "^1.271.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"query-string": "^9.2.2",
|
||||
"query-string": "^9.3.1",
|
||||
"raf-schd": "^4.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-color": "^2.19.3",
|
||||
"react-cookie": "^8.0.1",
|
||||
"lightningcss": "^1.30.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-drag-listview": "^2.0.0",
|
||||
"react-grid-gallery": "^1.0.1",
|
||||
@@ -73,7 +73,7 @@
|
||||
"react-resizable": "^3.0.5",
|
||||
"react-router-dom": "^6.30.0",
|
||||
"react-sticky": "^6.0.3",
|
||||
"react-virtuoso": "^4.14.0",
|
||||
"react-virtuoso": "^4.14.1",
|
||||
"recharts": "^2.15.2",
|
||||
"redux": "^5.0.1",
|
||||
"redux-actions": "^3.0.3",
|
||||
@@ -81,7 +81,7 @@
|
||||
"redux-saga": "^1.3.0",
|
||||
"redux-state-sync": "^3.1.4",
|
||||
"reselect": "^5.1.1",
|
||||
"sass": "^1.92.0",
|
||||
"sass": "^1.93.2",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"styled-components": "^6.1.19",
|
||||
"subscriptions-transport-ws": "^0.11.0",
|
||||
@@ -133,33 +133,33 @@
|
||||
"@rollup/rollup-linux-x64-gnu": "4.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@dotenvx/dotenvx": "^1.49.0",
|
||||
"@dotenvx/dotenvx": "^1.51.0",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@sentry/webpack-plugin": "^4.1.1",
|
||||
"@eslint/js": "^9.37.0",
|
||||
"@playwright/test": "^1.56.0",
|
||||
"@sentry/webpack-plugin": "^4.3.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"browserslist": "^4.25.3",
|
||||
"browserslist": "^4.26.3",
|
||||
"browserslist-to-esbuild": "^2.1.1",
|
||||
"chalk": "^5.6.0",
|
||||
"eslint": "^9.33.0",
|
||||
"chalk": "^5.6.2",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^15.15.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"memfs": "^4.36.3",
|
||||
"memfs": "^4.48.1",
|
||||
"os-browserify": "^0.3.0",
|
||||
"playwright": "^1.55.0",
|
||||
"playwright": "^1.56.0",
|
||||
"react-error-overlay": "^6.1.0",
|
||||
"redux-logger": "^3.0.6",
|
||||
"source-map-explorer": "^2.5.3",
|
||||
"vite": "^7.1.3",
|
||||
"vite": "^7.1.9",
|
||||
"vite-plugin-babel": "^1.3.2",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-plugin-node-polyfills": "^0.24.0",
|
||||
|
||||
@@ -12,6 +12,7 @@ import GlobalLoadingBar from "../components/global-loading-bar/global-loading-ba
|
||||
import { setDarkMode } from "../redux/application/application.actions";
|
||||
import { selectDarkMode } from "../redux/application/application.selectors";
|
||||
import { selectCurrentUser } from "../redux/user/user.selectors.js";
|
||||
import { signOutStart } from "../redux/user/user.actions";
|
||||
import client from "../utils/GraphQLClient";
|
||||
import App from "./App";
|
||||
import getTheme from "./themeProvider";
|
||||
@@ -20,14 +21,13 @@ import getTheme from "./themeProvider";
|
||||
const config = {
|
||||
core: {
|
||||
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
|
||||
key: "anon" // Default key, overridden dynamically by SplitClientProvider
|
||||
key: "anon"
|
||||
}
|
||||
};
|
||||
|
||||
// Custom provider to manage the Split client key based on imexshopid from Redux
|
||||
function SplitClientProvider({ children }) {
|
||||
const imexshopid = useSelector((state) => state.user.imexshopid); // Access imexshopid from Redux store
|
||||
const splitClient = useSplitClient({ key: imexshopid || "anon" }); // Use imexshopid or fallback to "anon"
|
||||
const imexshopid = useSelector((state) => state.user.imexshopid);
|
||||
const splitClient = useSplitClient({ key: imexshopid || "anon" });
|
||||
useEffect(() => {
|
||||
if (splitClient && imexshopid) {
|
||||
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
|
||||
@@ -36,40 +36,66 @@ function SplitClientProvider({ children }) {
|
||||
return children;
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setDarkMode: (isDarkMode) => dispatch(setDarkMode(isDarkMode))
|
||||
});
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
|
||||
function AppContainer({ currentUser, setDarkMode }) {
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setDarkMode: (isDarkMode) => dispatch(setDarkMode(isDarkMode)),
|
||||
signOutStart: () => dispatch(signOutStart())
|
||||
});
|
||||
|
||||
function AppContainer({ currentUser, setDarkMode, signOutStart }) {
|
||||
const { t } = useTranslation();
|
||||
const isDarkMode = useSelector(selectDarkMode);
|
||||
const theme = useMemo(() => getTheme(isDarkMode), [isDarkMode]);
|
||||
|
||||
// Update data-theme attribute when dark mode changes
|
||||
// Global seamless logout listener with redirect to /signin
|
||||
useEffect(() => {
|
||||
const handleSeamlessLogout = (event) => {
|
||||
if (event.data?.type !== "seamlessLogoutRequest") return;
|
||||
|
||||
const requestOrigin = event.origin;
|
||||
|
||||
if (currentUser?.authorized !== true) {
|
||||
window.parent.postMessage(
|
||||
{ type: "seamlessLogoutResponse", status: "already_logged_out" },
|
||||
requestOrigin || "*"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
signOutStart();
|
||||
window.parent.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, requestOrigin || "*");
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleSeamlessLogout);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleSeamlessLogout);
|
||||
};
|
||||
}, [signOutStart, currentUser]);
|
||||
|
||||
// Update data-theme attribute
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", isDarkMode ? "dark" : "light");
|
||||
return () => document.documentElement.removeAttribute("data-theme");
|
||||
}, [isDarkMode]);
|
||||
|
||||
// Sync Redux darkMode with localStorage on user change
|
||||
// Sync darkMode with localStorage
|
||||
useEffect(() => {
|
||||
if (currentUser?.uid) {
|
||||
const savedMode = localStorage.getItem(`dark-mode-${currentUser.uid}`);
|
||||
if (savedMode !== null) {
|
||||
setDarkMode(JSON.parse(savedMode));
|
||||
} else {
|
||||
setDarkMode(false); // default to light mode
|
||||
setDarkMode(false);
|
||||
}
|
||||
} else {
|
||||
setDarkMode(false);
|
||||
}
|
||||
}, [currentUser?.uid]);
|
||||
}, [currentUser?.uid, setDarkMode]);
|
||||
|
||||
// Persist darkMode to localStorage when it or user changes
|
||||
// Persist darkMode
|
||||
useEffect(() => {
|
||||
if (currentUser?.uid) {
|
||||
localStorage.setItem(`dark-mode-${currentUser.uid}`, JSON.stringify(isDarkMode));
|
||||
|
||||
@@ -138,7 +138,7 @@ export function App({
|
||||
);
|
||||
}
|
||||
|
||||
if (currentEula && !currentUser.eulaIsAccepted) {
|
||||
if (!isPartsEntry && currentEula && !currentUser.eulaIsAccepted) {
|
||||
return <Eula />;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility";
|
||||
import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility";
|
||||
import { handleUpload } from "../documents-upload/documents-upload.utility";
|
||||
import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility";
|
||||
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
billEnterModal: selectBillEnterModal,
|
||||
@@ -450,7 +451,9 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
setEnterAgain(false);
|
||||
}}
|
||||
>
|
||||
<BillFormContainer form={form} disableInvNumber={billEnterModal.context.disableInvNumber} />
|
||||
<RbacWrapper action="bills:enter">
|
||||
<BillFormContainer form={form} disableInvNumber={billEnterModal.context.disableInvNumber} />
|
||||
</RbacWrapper>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -45,7 +45,7 @@ export function BillEnterModalLinesComponent({
|
||||
title: t("billlines.fields.jobline"),
|
||||
dataIndex: "joblineid",
|
||||
editable: true,
|
||||
width: "20rem",
|
||||
minWidth: "10rem",
|
||||
formItemProps: (field) => {
|
||||
return {
|
||||
key: `${field.index}joblinename`,
|
||||
@@ -71,9 +71,9 @@ export function BillEnterModalLinesComponent({
|
||||
disabled={disabled}
|
||||
options={lineData}
|
||||
style={{
|
||||
width: "20rem",
|
||||
maxWidth: "20rem",
|
||||
minWidth: "10rem",
|
||||
//width: "10rem",
|
||||
// maxWidth: "20rem",
|
||||
minWidth: "20rem",
|
||||
whiteSpace: "normal",
|
||||
height: "auto",
|
||||
minHeight: "32px" // default height of Ant Design inputs
|
||||
@@ -110,7 +110,7 @@ export function BillEnterModalLinesComponent({
|
||||
title: t("billlines.fields.line_desc"),
|
||||
dataIndex: "line_desc",
|
||||
editable: true,
|
||||
width: "20rem",
|
||||
minWidth: "10rem",
|
||||
formItemProps: (field) => {
|
||||
return {
|
||||
key: `${field.index}line_desc`,
|
||||
@@ -232,7 +232,7 @@ export function BillEnterModalLinesComponent({
|
||||
title: t("billlines.fields.actual_cost"),
|
||||
dataIndex: "actual_cost",
|
||||
editable: true,
|
||||
width: "8rem",
|
||||
width: "10rem",
|
||||
|
||||
formItemProps: (field) => {
|
||||
return {
|
||||
@@ -357,6 +357,7 @@ export function BillEnterModalLinesComponent({
|
||||
title: t("billlines.labels.deductedfromlbr"),
|
||||
dataIndex: "deductedfromlbr",
|
||||
editable: true,
|
||||
width: "40px",
|
||||
formItemProps: (field) => {
|
||||
return {
|
||||
valuePropName: "checked",
|
||||
@@ -464,7 +465,7 @@ export function BillEnterModalLinesComponent({
|
||||
title: t("billlines.fields.federal_tax_applicable"),
|
||||
dataIndex: "applicable_taxes.federal",
|
||||
editable: true,
|
||||
|
||||
width: "40px",
|
||||
formItemProps: (field) => {
|
||||
return {
|
||||
key: `${field.index}fedtax`,
|
||||
@@ -485,7 +486,7 @@ export function BillEnterModalLinesComponent({
|
||||
title: t("billlines.fields.state_tax_applicable"),
|
||||
dataIndex: "applicable_taxes.state",
|
||||
editable: true,
|
||||
|
||||
width: "40px",
|
||||
formItemProps: (field) => {
|
||||
return {
|
||||
key: `${field.index}statetax`,
|
||||
@@ -503,7 +504,7 @@ export function BillEnterModalLinesComponent({
|
||||
title: t("billlines.fields.local_tax_applicable"),
|
||||
dataIndex: "applicable_taxes.local",
|
||||
editable: true,
|
||||
|
||||
width: "40px",
|
||||
formItemProps: (field) => {
|
||||
return {
|
||||
key: `${field.index}localtax`,
|
||||
|
||||
@@ -39,30 +39,32 @@ const BillLineSearchSelect = ({ options, disabled, allowRemoved, ...restProps },
|
||||
style: {
|
||||
...(item.removed ? { textDecoration: "line-through" } : {})
|
||||
},
|
||||
name: `${item.removed ? `(REMOVED) ` : ""}${item.line_desc}${
|
||||
item.oem_partno ? ` - ${item.oem_partno}` : ""
|
||||
}${item.alt_partno ? ` (${item.alt_partno})` : ""}`.trim(),
|
||||
label: (
|
||||
<div style={{ whiteSpace: "normal", wordBreak: "break-word" }}>
|
||||
<span>
|
||||
{`${item.removed ? `(REMOVED) ` : ""}${item.line_desc}${
|
||||
item.oem_partno ? ` - ${item.oem_partno}` : ""
|
||||
}${item.alt_partno ? ` (${item.alt_partno})` : ""}`.trim()}
|
||||
</span>
|
||||
{InstanceRenderMgr({
|
||||
rome: item.act_price === 0 && item.mod_lb_hrs > 0 && (
|
||||
<span style={{ float: "right", paddingleft: "1rem" }}>{`${item.mod_lb_hrs} units`}</span>
|
||||
)
|
||||
})}
|
||||
<span style={{ float: "right", paddingleft: "1rem" }}>
|
||||
{item.act_price ? `$${item.act_price && item.act_price.toFixed(2)}` : ``}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
name: generateLineName(item),
|
||||
label: generateLineName(item)
|
||||
}))
|
||||
]}
|
||||
{...restProps}
|
||||
></Select>
|
||||
);
|
||||
};
|
||||
|
||||
function generateLineName(item) {
|
||||
return (
|
||||
<div style={{ whiteSpace: "normal", wordBreak: "break-word" }}>
|
||||
<span>
|
||||
{`${item.removed ? `(REMOVED) ` : ""}${item.line_desc}${
|
||||
item.oem_partno ? ` - ${item.oem_partno}` : ""
|
||||
}${item.alt_partno ? ` (${item.alt_partno})` : ""}`.trim()}
|
||||
</span>
|
||||
{InstanceRenderMgr({
|
||||
rome: item.act_price === 0 && item.mod_lb_hrs > 0 && (
|
||||
<span style={{ float: "right", paddingleft: "1rem" }}>{`${item.mod_lb_hrs} units`}</span>
|
||||
)
|
||||
})}
|
||||
<span style={{ float: "right", paddingleft: "1rem" }}>
|
||||
{item.act_price ? `$${item.act_price && item.act_price.toFixed(2)}` : ``}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default forwardRef(BillLineSearchSelect);
|
||||
|
||||
@@ -40,7 +40,11 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
|
||||
variables: {
|
||||
jobId: conversation.job_conversations[0]?.jobid
|
||||
},
|
||||
skip: !open || !conversation.job_conversations || conversation.job_conversations.length === 0
|
||||
skip:
|
||||
!open ||
|
||||
!conversation.job_conversations ||
|
||||
conversation.job_conversations.length === 0 ||
|
||||
bodyshop.uselocalmediaserver
|
||||
});
|
||||
|
||||
const handleVisibleChange = (change) => {
|
||||
@@ -48,7 +52,8 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedMedia([]);
|
||||
// Instead of wiping the array (which holds media objects), just clear selection flags
|
||||
setSelectedMedia((prev) => prev.map((m) => ({ ...m, isSelected: false })));
|
||||
}, [setSelectedMedia, conversation]);
|
||||
|
||||
//Knowingly taking on the technical debt of poor implementation below. Done this way to avoid an edge case where no component may be displayed.
|
||||
@@ -75,6 +80,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
|
||||
<JobDocumentsLocalGalleryExternal
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
jobId={conversation.job_conversations[0]?.jobid}
|
||||
context="chat"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -90,6 +96,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
|
||||
<JobDocumentsLocalGalleryExternal
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
jobId={conversation.job_conversations[0]?.jobid}
|
||||
context="chat"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -110,6 +117,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
|
||||
trigger="click"
|
||||
open={open}
|
||||
onOpenChange={handleVisibleChange}
|
||||
destroyOnHidden
|
||||
classNames={{ root: "media-selector-popover" }}
|
||||
>
|
||||
<Badge count={selectedMedia.filter((s) => s.isSelected).length}>
|
||||
|
||||
@@ -142,17 +142,37 @@ export default function JobLifecycleDashboardComponent({ data, bodyshop, ...card
|
||||
title={t("job_lifecycle.content.legend_title")}
|
||||
style={{ marginTop: "10px" }}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 8
|
||||
}}
|
||||
>
|
||||
{lifecycleData.summations.map((key) => (
|
||||
<Tag key={key.status} color={key.color} style={{ width: "13vh", padding: "4px", margin: "4px" }}>
|
||||
<Tag
|
||||
key={key.status}
|
||||
color={key.color}
|
||||
style={{
|
||||
// IMPORTANT: let the tag grow with its content
|
||||
width: "auto",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxSizing: "border-box"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-label={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
|
||||
title={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
|
||||
style={{
|
||||
backgroundColor: "var(--tag-wrapper-bg)",
|
||||
color: "var(--tag-wrapper-text)",
|
||||
padding: "4px",
|
||||
textAlign: "center"
|
||||
padding: "4px 8px",
|
||||
textAlign: "center",
|
||||
whiteSpace: "nowrap" // keep it on one line while letting the pill expand
|
||||
}}
|
||||
>
|
||||
{key.status} [{lifecycleData.statusCounts[key.status]}] ({key.roundedPercentage})
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import Icon, { SyncOutlined } from "@ant-design/icons";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { Button, Dropdown, Space } from "antd";
|
||||
import { PageHeader } from "@ant-design/pro-layout";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { Responsive, WidthProvider } from "react-grid-layout";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MdClose } from "react-icons/md";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import { UPDATE_DASHBOARD_LAYOUT } from "../../graphql/user.queries";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import { UPDATE_DASHBOARD_LAYOUT, QUERY_USER_DASHBOARD_LAYOUT } from "../../graphql/user.queries";
|
||||
import { QUERY_DASHBOARD_BODYSHOP } from "../../graphql/bodyshop.queries";
|
||||
import { selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
|
||||
import { GenerateDashboardData } from "./dashboard-grid.utils";
|
||||
@@ -24,122 +24,173 @@ import "./dashboard-grid.styles.scss";
|
||||
const ResponsiveReactGridLayout = WidthProvider(Responsive);
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser,
|
||||
bodyshop: selectBodyshop
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
|
||||
const mapDispatchToProps = () => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
|
||||
export function DashboardGridComponent({ currentUser, bodyshop }) {
|
||||
export function DashboardGridComponent({ currentUser }) {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState(() => {
|
||||
const persisted = bodyshop.associations[0].user.dashboardlayout;
|
||||
// Normalize persisted structure to avoid malformed shapes that can cause recursive layout recalculations
|
||||
if (persisted) {
|
||||
return {
|
||||
items: Array.isArray(persisted.items) ? persisted.items : [],
|
||||
layout: Array.isArray(persisted.layout) ? persisted.layout : [],
|
||||
layouts: typeof persisted.layouts === "object" && !Array.isArray(persisted.layouts) ? persisted.layouts : {},
|
||||
cols: persisted.cols
|
||||
};
|
||||
}
|
||||
return { items: [], layout: [], layouts: {}, cols: 12 };
|
||||
});
|
||||
const notification = useNotification();
|
||||
|
||||
// Memoize the query document so Apollo doesn't treat each render as a brand-new query causing continuous re-fetches
|
||||
const dashboardQueryDoc = useMemo(() => createDashboardQuery(state.items), [state.items]);
|
||||
// Constants for layout defaults
|
||||
const DEFAULT_COLS = 12;
|
||||
const DEFAULT_Y_POSITION = 1000;
|
||||
const GRID_BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
|
||||
const GRID_COLS = { lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 };
|
||||
|
||||
const { loading, error, data, refetch } = useQuery(dashboardQueryDoc, {
|
||||
// Fetch dashboard layout data
|
||||
const { data: layoutData } = useQuery(QUERY_USER_DASHBOARD_LAYOUT, {
|
||||
variables: { email: currentUser.email },
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only",
|
||||
skip: !currentUser?.email
|
||||
});
|
||||
|
||||
// Fetch minimal bodyshop data for components
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
data: bodyshopData
|
||||
} = useQuery(QUERY_DASHBOARD_BODYSHOP, {
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only"
|
||||
});
|
||||
|
||||
const [updateLayout] = useMutation(UPDATE_DASHBOARD_LAYOUT);
|
||||
|
||||
const handleLayoutChange = async (layout, layouts) => {
|
||||
// Memoize layout state initialization
|
||||
const initialState = useMemo(() => {
|
||||
const persisted = layoutData?.users?.[0]?.dashboardlayout;
|
||||
if (persisted) {
|
||||
const { items = [], layout = [], layouts = {}, cols = DEFAULT_COLS } = persisted;
|
||||
return {
|
||||
items: Array.isArray(items) ? items : [],
|
||||
layout: Array.isArray(layout) ? layout : [],
|
||||
layouts: typeof layouts === "object" && !Array.isArray(layouts) ? layouts : {},
|
||||
cols
|
||||
};
|
||||
}
|
||||
return { items: [], layout: [], layouts: {}, cols: DEFAULT_COLS };
|
||||
}, [layoutData]);
|
||||
|
||||
const [state, setState] = useState(initialState);
|
||||
|
||||
// Update state when layout data changes
|
||||
useEffect(() => {
|
||||
if (layoutData?.users?.[0]?.dashboardlayout) {
|
||||
const { items = [], layout = [], layouts = {}, cols = DEFAULT_COLS } = layoutData.users[0].dashboardlayout;
|
||||
setState({
|
||||
items: Array.isArray(items) ? items : [],
|
||||
layout: Array.isArray(layout) ? layout : [],
|
||||
layouts: typeof layouts === "object" && !Array.isArray(layouts) ? layouts : {},
|
||||
cols
|
||||
});
|
||||
}
|
||||
}, [layoutData]);
|
||||
|
||||
// Get bodyshop data for components
|
||||
const bodyshop = bodyshopData?.dashboard_bodyshops?.[0];
|
||||
|
||||
// DRY helper function to update layout in database and cache
|
||||
const updateLayoutAndCache = async (updatedLayout, errorContext = "updating layout") => {
|
||||
try {
|
||||
logImEXEvent("dashboard_change_layout");
|
||||
|
||||
setState((prev) => ({ ...prev, layout, layouts }));
|
||||
|
||||
const result = await updateLayout({
|
||||
variables: {
|
||||
email: currentUser.email,
|
||||
layout: { ...state, layout, layouts }
|
||||
}
|
||||
const { data: result } = await updateLayout({
|
||||
variables: { email: currentUser.email, layout: updatedLayout }
|
||||
});
|
||||
|
||||
if (result?.errors && result.errors.length) {
|
||||
const errorMessages = result.errors.map((e) => e?.message || String(e));
|
||||
const { errors = [] } = result?.update_users?.returning?.[0] || {};
|
||||
|
||||
if (errors.length) {
|
||||
const errorMessages = errors.map(({ message }) => message || String(error));
|
||||
notification.error({
|
||||
message: t("dashboard.errors.updatinglayout", {
|
||||
message: errorMessages.join("; ")
|
||||
})
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Catch any unexpected errors (including potential cyclic JSON issues) so the promise never rejects unhandled
|
||||
console.error("Dashboard layout update failed", err);
|
||||
console.error(`Dashboard ${errorContext} failed`, err);
|
||||
notification.error({
|
||||
message: t("dashboard.errors.updatinglayout", {
|
||||
message: err?.message || String(err)
|
||||
})
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveComponent = (key) => {
|
||||
// Memoize the query document so Apollo doesn't treat each render as a brand-new query causing continuous re-fetches
|
||||
const dashboardQueryDoc = useMemo(() => createDashboardQuery(state.items), [state.items]);
|
||||
|
||||
const {
|
||||
loading: dashboardLoading,
|
||||
error: dashboardError,
|
||||
data: dashboardQueryData,
|
||||
refetch
|
||||
} = useQuery(dashboardQueryDoc, {
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only"
|
||||
});
|
||||
|
||||
const dashboardData = useMemo(() => GenerateDashboardData(dashboardQueryData), [dashboardQueryData]);
|
||||
|
||||
// Memoize existing layout keys to prevent unnecessary recalculations
|
||||
const existingLayoutKeys = useMemo(() => state.items.map(({ i }) => i), [state.items]);
|
||||
|
||||
// Memoize menu items to prevent unnecessary recalculations
|
||||
const menuItems = useMemo(
|
||||
() =>
|
||||
Object.entries(componentList).map(([key, { label }]) => ({
|
||||
key,
|
||||
label,
|
||||
value: key,
|
||||
disabled: existingLayoutKeys.includes(key)
|
||||
})),
|
||||
[existingLayoutKeys]
|
||||
);
|
||||
|
||||
if (loading || dashboardLoading) return <LoadingSkeleton message={t("general.labels.loading")} />;
|
||||
if (error || dashboardError) return <AlertComponent message={(error || dashboardError).message} type="error" />;
|
||||
|
||||
const handleLayoutChange = async (layout, layouts) => {
|
||||
logImEXEvent("dashboard_change_layout");
|
||||
setState((prev) => ({ ...prev, layout, layouts }));
|
||||
await updateLayoutAndCache({ ...state, layout, layouts }, "layout change");
|
||||
};
|
||||
|
||||
const handleRemoveComponent = async (key) => {
|
||||
logImEXEvent("dashboard_remove_component", { name: key });
|
||||
const idxToRemove = state.items.findIndex((i) => i.i === key);
|
||||
|
||||
const items = cloneDeep(state.items);
|
||||
|
||||
items.splice(idxToRemove, 1);
|
||||
setState({ ...state, items });
|
||||
const updatedState = { ...state, items: state.items.filter((item) => item.i !== key) };
|
||||
setState(updatedState);
|
||||
await updateLayoutAndCache(updatedState, "component removal");
|
||||
};
|
||||
|
||||
const handleAddComponent = (e) => {
|
||||
// Avoid passing the full AntD menu click event (contains circular refs) to analytics
|
||||
logImEXEvent("dashboard_add_component", { key: e.key });
|
||||
const compSpec = componentList[e.key] || {};
|
||||
const minW = compSpec.minW || 1;
|
||||
const minH = compSpec.minH || 1;
|
||||
const baseW = compSpec.w || 2;
|
||||
const baseH = compSpec.h || 2;
|
||||
setState((prev) => {
|
||||
const nextItems = [
|
||||
...prev.items,
|
||||
{
|
||||
i: e.key,
|
||||
// Position near bottom: use a large y so RGL places it last without triggering cascading relayout loops
|
||||
x: (prev.items.length * 2) % (prev.cols || 12),
|
||||
y: 1000,
|
||||
w: Math.max(baseW, minW),
|
||||
h: Math.max(baseH, minH)
|
||||
}
|
||||
];
|
||||
return { ...prev, items: nextItems };
|
||||
});
|
||||
const handleAddComponent = async ({ key }) => {
|
||||
logImEXEvent("dashboard_add_component", { key });
|
||||
const { minW = 1, minH = 1, w: baseW = 2, h: baseH = 2 } = componentList[key] || {};
|
||||
const nextItems = [
|
||||
...state.items,
|
||||
{
|
||||
i: key,
|
||||
x: (state.items.length * 2) % (state.cols || DEFAULT_COLS),
|
||||
y: DEFAULT_Y_POSITION,
|
||||
w: Math.max(baseW, minW),
|
||||
h: Math.max(baseH, minH)
|
||||
}
|
||||
];
|
||||
const updatedState = { ...state, items: nextItems };
|
||||
setState(updatedState);
|
||||
await updateLayoutAndCache(updatedState, "component addition");
|
||||
};
|
||||
|
||||
const dashboardData = useMemo(() => GenerateDashboardData(data), [data]);
|
||||
|
||||
const existingLayoutKeys = state.items.map((i) => i.i);
|
||||
|
||||
const menuItems = Object.keys(componentList).map((key) => ({
|
||||
key: key,
|
||||
label: componentList[key].label,
|
||||
value: key,
|
||||
disabled: existingLayoutKeys.includes(key)
|
||||
}));
|
||||
|
||||
const menu = { items: menuItems, onClick: handleAddComponent };
|
||||
|
||||
if (error) return <AlertComponent message={error.message} type="error" />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
@@ -157,22 +208,19 @@ export function DashboardGridComponent({ currentUser, bodyshop }) {
|
||||
|
||||
<ResponsiveReactGridLayout
|
||||
className="layout"
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
||||
breakpoints={GRID_BREAKPOINTS}
|
||||
cols={GRID_COLS}
|
||||
layouts={state.layouts}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
>
|
||||
{state.items.map((item) => {
|
||||
const spec = componentList[item.i] || {};
|
||||
const TheComponent = spec.component;
|
||||
const minW = spec.minW || 1;
|
||||
const minH = spec.minH || 1;
|
||||
// Ensure current width/height respect minimums to avoid react-grid-layout prop warnings
|
||||
const { component: TheComponent, minW = 1, minH = 1, w: specW, h: specH } = componentList[item.i] || {};
|
||||
const safeItem = {
|
||||
...item,
|
||||
w: Math.max(item.w || spec.w || minW, minW),
|
||||
h: Math.max(item.h || spec.h || minH, minH)
|
||||
w: Math.max(item.w || specW || minW, minW),
|
||||
h: Math.max(item.h || specH || minH, minH)
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={safeItem.i}
|
||||
|
||||
@@ -67,6 +67,7 @@ export function EmailDocumentsComponent({ emailConfig, form, selectedMediaState,
|
||||
<JobsDocumentsLocalGalleryExternalComponent
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
jobId={emailConfig.jobid}
|
||||
context="email"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -82,6 +83,7 @@ export function EmailDocumentsComponent({ emailConfig, form, selectedMediaState,
|
||||
<JobsDocumentsLocalGalleryExternalComponent
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
jobId={emailConfig.jobid}
|
||||
context="email"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -55,7 +55,8 @@ const Eula = ({ currentEula, currentUser, acceptEula }) => {
|
||||
const useremail = currentUser.email;
|
||||
|
||||
try {
|
||||
const { ...otherFormValues } = formValues;
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { accepted_terms, ...otherFormValues } = formValues;
|
||||
|
||||
// Trim the values of the fields before submitting
|
||||
const trimmedFormValues = Object.entries(otherFormValues).reduce((acc, [key, value]) => {
|
||||
|
||||
@@ -9,10 +9,10 @@ import {
|
||||
WarningFilled
|
||||
} from "@ant-design/icons";
|
||||
import { PageHeader } from "@ant-design/pro-layout";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { Button, Dropdown, Input, Space, Table, Tag } from "antd";
|
||||
import { gql, useMutation } from "@apollo/client";
|
||||
import { Button, Dropdown, Input, Modal, Select, Space, Table, Tag, Typography } from "antd";
|
||||
import axios from "axios";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -47,6 +47,19 @@ import JobLinesExpander from "./job-lines-expander.component";
|
||||
import JobLinesPartPriceChange from "./job-lines-part-price-change.component";
|
||||
import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
const UPDATE_JOB_LINES_LOCATION_BULK = gql`
|
||||
mutation UPDATE_JOB_LINES_LOCATION_BULK($ids: [uuid!]!, $location: String!) {
|
||||
update_joblines(where: { id: { _in: $ids } }, _set: { location: $location }) {
|
||||
affected_rows
|
||||
returning {
|
||||
id
|
||||
location
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
@@ -83,6 +96,9 @@ export function JobLinesComponent({
|
||||
isPartsEntry
|
||||
}) {
|
||||
const [deleteJobLine] = useMutation(DELETE_JOB_LINE_BY_PK);
|
||||
const [bulkUpdateLocations] = useMutation(UPDATE_JOB_LINES_LOCATION_BULK);
|
||||
const notification = useNotification();
|
||||
|
||||
const {
|
||||
treatments: { Enhanced_Payroll }
|
||||
} = useSplitTreatments({
|
||||
@@ -97,15 +113,89 @@ export function JobLinesComponent({
|
||||
filteredInfo: {
|
||||
...(isPartsEntry
|
||||
? {
|
||||
part_type: ["PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG", "PAO"]
|
||||
part_type: ["PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG"] //"PAO" Removed by request
|
||||
}
|
||||
: {})
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk location modal state
|
||||
const [bulkLocationOpen, setBulkLocationOpen] = useState(false);
|
||||
const [bulkLocation, setBulkLocation] = useState(null);
|
||||
const [bulkLocationSaving, setBulkLocationSaving] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const jobIsPrivate = bodyshop.md_ins_cos.find((c) => c.name === job.ins_co_nm)?.private;
|
||||
|
||||
const selectedLineIds = useMemo(() => selectedLines.map((l) => l?.id).filter(Boolean), [selectedLines]);
|
||||
|
||||
const commonSelectedLocation = useMemo(() => {
|
||||
const locs = selectedLines
|
||||
.map((l) => (typeof l?.location === "string" ? l.location : ""))
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (locs.length === 0) return null;
|
||||
|
||||
const uniq = _.uniq(locs);
|
||||
return uniq.length === 1 ? uniq[0] : null;
|
||||
}, [selectedLines]);
|
||||
|
||||
const openBulkLocationModal = () => {
|
||||
setBulkLocation(commonSelectedLocation);
|
||||
setBulkLocationOpen(true);
|
||||
logImEXEvent("joblines_bulk_location_open", { count: selectedLineIds.length });
|
||||
};
|
||||
|
||||
const closeBulkLocationModal = () => {
|
||||
setBulkLocationOpen(false);
|
||||
setBulkLocation(null);
|
||||
};
|
||||
|
||||
const saveBulkLocation = async () => {
|
||||
if (selectedLineIds.length === 0) return;
|
||||
|
||||
setBulkLocationSaving(true);
|
||||
|
||||
try {
|
||||
const locationToSave = (bulkLocation ?? "").toString();
|
||||
|
||||
const result = await bulkUpdateLocations({
|
||||
variables: {
|
||||
ids: selectedLineIds,
|
||||
location: locationToSave
|
||||
}
|
||||
});
|
||||
|
||||
if (!result?.errors) {
|
||||
// Keep UI selection consistent without waiting for refetch
|
||||
setSelectedLines((prev) =>
|
||||
prev.map((l) => (l && selectedLineIds.includes(l.id) ? { ...l, location: locationToSave } : l))
|
||||
);
|
||||
|
||||
notification["success"]({ message: t("joblines.successes.saved") });
|
||||
|
||||
logImEXEvent("joblines_bulk_location_saved", {
|
||||
count: selectedLineIds.length,
|
||||
location: locationToSave
|
||||
});
|
||||
|
||||
closeBulkLocationModal();
|
||||
if (refetch) refetch();
|
||||
} else {
|
||||
notification["error"]({
|
||||
message: t("joblines.errors.saving", { error: JSON.stringify(result.errors) })
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
notification["error"]({
|
||||
message: t("joblines.errors.saving", { error: error?.message || String(error) })
|
||||
});
|
||||
} finally {
|
||||
setBulkLocationSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "#",
|
||||
@@ -171,46 +261,16 @@ export function JobLinesComponent({
|
||||
? ["PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG", "PAO"]
|
||||
: ["PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG"]
|
||||
},
|
||||
{
|
||||
text: t("joblines.fields.part_types.PAN"),
|
||||
value: ["PAN"]
|
||||
},
|
||||
{
|
||||
text: t("joblines.fields.part_types.PAP"),
|
||||
value: ["PAP"]
|
||||
},
|
||||
{
|
||||
text: t("joblines.fields.part_types.PAL"),
|
||||
value: ["PAL"]
|
||||
},
|
||||
{
|
||||
text: t("joblines.fields.part_types.PAA"),
|
||||
value: ["PAA"]
|
||||
},
|
||||
{
|
||||
text: t("joblines.fields.part_types.PAG"),
|
||||
value: ["PAG"]
|
||||
},
|
||||
{
|
||||
text: t("joblines.fields.part_types.PAS"),
|
||||
value: ["PAS"]
|
||||
},
|
||||
{
|
||||
text: t("joblines.fields.part_types.PASL"),
|
||||
value: ["PASL"]
|
||||
},
|
||||
{
|
||||
text: t("joblines.fields.part_types.PAC"),
|
||||
value: ["PAC"]
|
||||
},
|
||||
{
|
||||
text: t("joblines.fields.part_types.PAR"),
|
||||
value: ["PAR"]
|
||||
},
|
||||
{
|
||||
text: t("joblines.fields.part_types.PAM"),
|
||||
value: ["PAM"]
|
||||
},
|
||||
{ text: t("joblines.fields.part_types.PAN"), value: ["PAN"] },
|
||||
{ text: t("joblines.fields.part_types.PAP"), value: ["PAP"] },
|
||||
{ text: t("joblines.fields.part_types.PAL"), value: ["PAL"] },
|
||||
{ text: t("joblines.fields.part_types.PAA"), value: ["PAA"] },
|
||||
{ text: t("joblines.fields.part_types.PAG"), value: ["PAG"] },
|
||||
{ text: t("joblines.fields.part_types.PAS"), value: ["PAS"] },
|
||||
{ text: t("joblines.fields.part_types.PASL"), value: ["PASL"] },
|
||||
{ text: t("joblines.fields.part_types.PAC"), value: ["PAC"] },
|
||||
{ text: t("joblines.fields.part_types.PAR"), value: ["PAR"] },
|
||||
{ text: t("joblines.fields.part_types.PAM"), value: ["PAM"] },
|
||||
...(isPartsEntry
|
||||
? [
|
||||
{
|
||||
@@ -220,7 +280,6 @@ export function JobLinesComponent({
|
||||
]
|
||||
: [])
|
||||
],
|
||||
|
||||
onFilter: (value, record) => value.includes(record.part_type),
|
||||
render: (text, record) => (record.part_type ? t(`joblines.fields.part_types.${record.part_type}`) : null)
|
||||
},
|
||||
@@ -246,7 +305,6 @@ export function JobLinesComponent({
|
||||
title: t("joblines.fields.mod_lbr_ty"),
|
||||
dataIndex: "mod_lbr_ty",
|
||||
key: "mod_lbr_ty",
|
||||
|
||||
sorter: (a, b) => alphaSort(a.mod_lbr_ty, b.mod_lbr_ty),
|
||||
sortOrder: state.sortedInfo.columnKey === "mod_lbr_ty" && state.sortedInfo.order,
|
||||
render: (text, record) => (record.mod_lbr_ty ? t(`joblines.fields.lbr_types.${record.mod_lbr_ty}`) : null)
|
||||
@@ -255,7 +313,6 @@ export function JobLinesComponent({
|
||||
title: t("joblines.fields.mod_lb_hrs"),
|
||||
dataIndex: "mod_lb_hrs",
|
||||
key: "mod_lb_hrs",
|
||||
|
||||
sorter: (a, b) => a.mod_lb_hrs - b.mod_lb_hrs,
|
||||
sortOrder: state.sortedInfo.columnKey === "mod_lb_hrs" && state.sortedInfo.order
|
||||
},
|
||||
@@ -310,18 +367,12 @@ export function JobLinesComponent({
|
||||
sorter: (a, b) => alphaSort(a.status, b.status),
|
||||
sortOrder: state.sortedInfo.columnKey === "status" && state.sortedInfo.order,
|
||||
filteredValue: state.filteredInfo.status || null,
|
||||
|
||||
filters:
|
||||
(jobLines &&
|
||||
jobLines
|
||||
.map((l) => l.status)
|
||||
.filter(onlyUnique)
|
||||
.map((s) => {
|
||||
return {
|
||||
text: s || t("dashboard.errors.status"),
|
||||
value: [s]
|
||||
};
|
||||
})) ||
|
||||
.map((s) => ({ text: s || t("dashboard.errors.status"), value: [s] }))) ||
|
||||
[],
|
||||
onFilter: (value, record) => value.includes(record.status),
|
||||
render: (text, record) => <JobLineStatusPopup jobline={record} disabled={jobRO} />
|
||||
@@ -376,9 +427,7 @@ export function JobLinesComponent({
|
||||
});
|
||||
}
|
||||
});
|
||||
await axios.post("/job/totalsssu", {
|
||||
id: job.id
|
||||
});
|
||||
await axios.post("/job/totalsssu", { id: job.id });
|
||||
if (refetch) refetch();
|
||||
}}
|
||||
>
|
||||
@@ -448,6 +497,36 @@ export function JobLinesComponent({
|
||||
return (
|
||||
<div>
|
||||
<PartsOrderModalContainer />
|
||||
|
||||
<Modal
|
||||
open={bulkLocationOpen}
|
||||
title={t("joblines.actions.updatelocation")}
|
||||
onCancel={closeBulkLocationModal}
|
||||
onOk={saveBulkLocation}
|
||||
okButtonProps={{
|
||||
disabled: jobRO || technician || selectedLineIds.length === 0,
|
||||
loading: bulkLocationSaving
|
||||
}}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<Typography.Text type="secondary">
|
||||
{t("general.labels.selected")}: {selectedLineIds.length}
|
||||
</Typography.Text>
|
||||
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t("joblines.fields.location")}
|
||||
value={bulkLocation}
|
||||
style={{ width: "100%" }}
|
||||
popupMatchSelectWidth={false}
|
||||
onChange={(val) => setBulkLocation(val ?? null)}
|
||||
options={(bodyshop?.md_parts_locations || []).map((loc) => ({ label: loc, value: loc }))}
|
||||
/>
|
||||
<Typography.Text type="secondary">{t("joblines.labels.bulk_location_help")}</Typography.Text>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
{!technician && (
|
||||
<PartsOrderDrawer
|
||||
job={job}
|
||||
@@ -457,6 +536,7 @@ export function JobLinesComponent({
|
||||
setTaskUpsertContext={setTaskUpsertContext}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PageHeader
|
||||
title={t("jobs.labels.estimatelines")}
|
||||
extra={
|
||||
@@ -465,6 +545,16 @@ export function JobLinesComponent({
|
||||
<SyncOutlined />
|
||||
</Button>
|
||||
|
||||
{/* Bulk Update Location */}
|
||||
<Button
|
||||
id="job-lines-bulk-update-location-button"
|
||||
disabled={jobRO || technician || selectedLineIds.length === 0}
|
||||
onClick={openBulkLocationModal}
|
||||
>
|
||||
{t("joblines.actions.updatelocation")}
|
||||
{selectedLineIds.length > 0 && ` (${selectedLineIds.length})`}
|
||||
</Button>
|
||||
|
||||
{job.special_coverage_policy && (
|
||||
<Tag color="tomato">
|
||||
<Space>
|
||||
@@ -473,6 +563,7 @@ export function JobLinesComponent({
|
||||
</Space>
|
||||
</Tag>
|
||||
)}
|
||||
|
||||
{!isPartsEntry && (
|
||||
<JobLineDispatchButton
|
||||
selectedLines={selectedLines}
|
||||
@@ -481,9 +572,11 @@ export function JobLinesComponent({
|
||||
disabled={technician}
|
||||
/>
|
||||
)}
|
||||
|
||||
{Enhanced_Payroll.treatment === "on" && (
|
||||
<JobLineBulkAssignComponent selectedLines={selectedLines} setSelectedLines={setSelectedLines} job={job} />
|
||||
)}
|
||||
|
||||
{!isPartsEntry && (
|
||||
<Button
|
||||
disabled={(job && !job.converted) || (selectedLines.length > 0 ? false : true) || jobRO || technician}
|
||||
@@ -499,27 +592,20 @@ export function JobLinesComponent({
|
||||
isinhouse: true,
|
||||
date: dayjs(),
|
||||
total: 0,
|
||||
billlines: selectedLines.map((p) => {
|
||||
return {
|
||||
joblineid: p.id,
|
||||
actual_price: p.act_price,
|
||||
actual_cost: 0, //p.act_price,
|
||||
line_desc: p.line_desc,
|
||||
line_remarks: p.line_remarks,
|
||||
part_type: p.part_type,
|
||||
quantity: p.quantity || 1,
|
||||
applicable_taxes: {
|
||||
local: false,
|
||||
state: false,
|
||||
federal: false
|
||||
}
|
||||
};
|
||||
})
|
||||
billlines: selectedLines.map((p) => ({
|
||||
joblineid: p.id,
|
||||
actual_price: p.act_price,
|
||||
actual_cost: 0,
|
||||
line_desc: p.line_desc,
|
||||
line_remarks: p.line_remarks,
|
||||
part_type: p.part_type,
|
||||
quantity: p.quantity || 1,
|
||||
applicable_taxes: { local: false, state: false, federal: false }
|
||||
}))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
//Clear out the selected lines. IO-785
|
||||
setSelectedLines([]);
|
||||
}}
|
||||
>
|
||||
@@ -528,6 +614,7 @@ export function JobLinesComponent({
|
||||
{selectedLines.length > 0 && ` (${selectedLines.length})`}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
id="job-lines-order-parts-button"
|
||||
disabled={(job && !job.converted) || (selectedLines.length > 0 ? false : true) || jobRO || technician}
|
||||
@@ -544,13 +631,13 @@ export function JobLinesComponent({
|
||||
}
|
||||
});
|
||||
|
||||
//Clear out the selected lines. IO-785
|
||||
setSelectedLines([]);
|
||||
}}
|
||||
>
|
||||
{t("parts.actions.order")}
|
||||
{selectedLines.length > 0 && ` (${selectedLines.length})`}
|
||||
</Button>
|
||||
|
||||
{!isPartsEntry && (
|
||||
<Button
|
||||
id="job-lines-filter-parts-only-button"
|
||||
@@ -567,9 +654,11 @@ export function JobLinesComponent({
|
||||
<FilterFilled /> {t("jobs.actions.filterpartsonly")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dropdown menu={markMenu} trigger={["click"]}>
|
||||
<Button id="repair-data-mark-button">{t("jobs.actions.mark")}</Button>
|
||||
</Dropdown>
|
||||
|
||||
{!isPartsEntry && (
|
||||
<Button
|
||||
disabled={jobRO || technician}
|
||||
@@ -583,9 +672,12 @@ export function JobLinesComponent({
|
||||
{t("joblines.actions.new")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isPartsEntry &&
|
||||
InstanceRenderManager({ rome: <JobSendPartPriceChangeComponent job={job} disabled={technician} /> })}
|
||||
|
||||
<JobCreateIOU job={job} selectedJobLines={selectedLines} />
|
||||
|
||||
<Input.Search
|
||||
placeholder={t("general.labels.search")}
|
||||
onChange={(e) => {
|
||||
@@ -596,6 +688,7 @@ export function JobLinesComponent({
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
@@ -603,9 +696,7 @@ export function JobLinesComponent({
|
||||
pagination={false}
|
||||
dataSource={jobLines}
|
||||
onChange={handleTableChange}
|
||||
scroll={{
|
||||
x: true
|
||||
}}
|
||||
scroll={{ x: true }}
|
||||
expandable={{
|
||||
expandedRowRender: (record) =>
|
||||
isPartsEntry ? (
|
||||
@@ -614,7 +705,6 @@ export function JobLinesComponent({
|
||||
<JobLinesExpander jobline={record} jobid={job.id} />
|
||||
),
|
||||
rowExpandable: () => true,
|
||||
//expandRowByClick: true,
|
||||
expandIcon: ({ expanded, onExpand, record }) =>
|
||||
expanded ? (
|
||||
<MinusCircleTwoTone onClick={(e) => onExpand(record, e)} />
|
||||
@@ -627,17 +717,15 @@ export function JobLinesComponent({
|
||||
/>
|
||||
)
|
||||
}}
|
||||
onRow={(record) => {
|
||||
return {
|
||||
onDoubleClick: () => {
|
||||
logImEXEvent("joblines_double_click_select", {});
|
||||
const notMatchingLines = selectedLines.filter((i) => i.id !== record.id);
|
||||
notMatchingLines.length !== selectedLines.length
|
||||
? setSelectedLines(notMatchingLines)
|
||||
: setSelectedLines([...selectedLines, record]);
|
||||
} // double click row
|
||||
};
|
||||
}}
|
||||
onRow={(record) => ({
|
||||
onDoubleClick: () => {
|
||||
logImEXEvent("joblines_double_click_select", {});
|
||||
const notMatchingLines = selectedLines.filter((i) => i.id !== record.id);
|
||||
notMatchingLines.length !== selectedLines.length
|
||||
? setSelectedLines(notMatchingLines)
|
||||
: setSelectedLines([...selectedLines, record]);
|
||||
}
|
||||
})}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedLines.map((item) => item && item.id),
|
||||
onSelectAll: (selected, selectedRows) => {
|
||||
|
||||
@@ -222,17 +222,37 @@ export function JobLifecycleComponent({ bodyshop, job, statuses }) {
|
||||
</div>
|
||||
</BlurWrapperComponent>
|
||||
<Card type="inner" title={t("job_lifecycle.content.legend_title")} style={{ marginTop: "10px" }}>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 8
|
||||
}}
|
||||
>
|
||||
{lifecycleData.durations.summations.map((key) => (
|
||||
<Tag key={key.status} color={key.color} style={{ width: "13vh", padding: "4px", margin: "4px" }}>
|
||||
<Tag
|
||||
key={key.status}
|
||||
color={key.color}
|
||||
style={{
|
||||
// let the tag grow with its content
|
||||
width: "auto",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxSizing: "border-box"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-label={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
|
||||
title={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
|
||||
style={{
|
||||
backgroundColor: "var(--tag-wrapper-bg)",
|
||||
color: "var(--tag-wrapper-text)",
|
||||
padding: "4px",
|
||||
textAlign: "center"
|
||||
padding: "4px 8px",
|
||||
textAlign: "center",
|
||||
whiteSpace: "nowrap" // single line; tag gets wider instead of text escaping
|
||||
}}
|
||||
>
|
||||
{key.status} (
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { Select, Space } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Select, Space, Tag } from "antd";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { UPDATE_JOB_LINE } from "../../graphql/jobs-lines.queries";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
//currentUser: selectCurrentUser
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = () => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
const CLEAR_VALUE = "__CLEAR_LOCATION__";
|
||||
|
||||
export function JobLineLocationPopup({ bodyshop, jobline, disabled }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [location, setLocation] = useState(jobline.location);
|
||||
const [updateJob] = useMutation(UPDATE_JOB_LINE);
|
||||
const { t } = useTranslation();
|
||||
@@ -29,55 +27,78 @@ export function JobLineLocationPopup({ bodyshop, jobline, disabled }) {
|
||||
if (editing) setLocation(jobline.location);
|
||||
}, [editing, jobline.location]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
setLocation(e);
|
||||
};
|
||||
const options = useMemo(() => {
|
||||
const locs = bodyshop?.md_parts_locations || [];
|
||||
return [
|
||||
{ label: t("general.labels.none", "No location"), value: CLEAR_VALUE },
|
||||
...locs.map((loc) => ({ label: loc, value: loc }))
|
||||
];
|
||||
}, [bodyshop?.md_parts_locations, t]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
const result = await updateJob({
|
||||
variables: { lineId: jobline.id, line: { location: location || "" } }
|
||||
});
|
||||
const saveLocation = async (nextLocation) => {
|
||||
setSaving(true);
|
||||
|
||||
if (!result.errors) {
|
||||
notification["success"]({ message: t("joblines.successes.saved") });
|
||||
} else {
|
||||
notification["error"]({
|
||||
message: t("joblines.errors.saving", {
|
||||
error: JSON.stringify(result.errors)
|
||||
})
|
||||
try {
|
||||
const result = await updateJob({
|
||||
variables: { lineId: jobline.id, line: { location: nextLocation || "" } }
|
||||
});
|
||||
|
||||
if (!result.errors) {
|
||||
notification["success"]({ message: t("joblines.successes.saved") });
|
||||
} else {
|
||||
notification["error"]({
|
||||
message: t("joblines.errors.saving", {
|
||||
error: JSON.stringify(result.errors)
|
||||
})
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
notification["error"]({
|
||||
message: t("joblines.errors.saving", { error: error?.message || String(error) })
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setEditing(false);
|
||||
}
|
||||
setLoading(false);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
if (editing)
|
||||
const handleChange = async (value) => {
|
||||
const next = value === CLEAR_VALUE ? null : value;
|
||||
setLocation(next);
|
||||
await saveLocation(next);
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div>
|
||||
<LoadingSpinner loading={loading}>
|
||||
<Select
|
||||
autoFocus
|
||||
allowClear
|
||||
popupMatchSelectWidth={100}
|
||||
value={location}
|
||||
onClear={() => setLocation(null)}
|
||||
onSelect={handleChange}
|
||||
onBlur={handleSave}
|
||||
>
|
||||
{bodyshop.md_parts_locations.map((loc, idx) => (
|
||||
<Select.Option key={idx} value={loc}>
|
||||
{loc}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</LoadingSpinner>
|
||||
<div style={{ width: "100%", display: "flex", alignItems: "center" }}>
|
||||
<Select
|
||||
autoFocus
|
||||
size="small"
|
||||
value={location ?? undefined}
|
||||
loading={saving}
|
||||
disabled={saving}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
popupMatchSelectWidth={false}
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentNode}
|
||||
onChange={handleChange}
|
||||
onBlur={() => !saving && setEditing(false)}
|
||||
options={options}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", minHeight: "2rem", cursor: "pointer" }} onClick={() => !disabled && setEditing(true)}>
|
||||
<div
|
||||
style={{ width: "100%", minHeight: "2rem", cursor: disabled ? "default" : "pointer" }}
|
||||
onClick={() => !disabled && setEditing(true)}
|
||||
>
|
||||
<Space wrap>
|
||||
{jobline.location}
|
||||
{jobline.location ? (
|
||||
<Tag>{jobline.location}</Tag>
|
||||
) : (
|
||||
<span style={{ opacity: 0.6 }}>{t("general.labels.none")}</span>
|
||||
)}
|
||||
{jobline.parts_dispatch_lines?.length > 0 && "-Disp"}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
@@ -609,7 +609,7 @@ export function JobsDetailHeaderActions({
|
||||
<FormDateTimePickerComponent
|
||||
onBlur={() => {
|
||||
const start = form.getFieldValue("start");
|
||||
form.setFieldsValue({ end: start.add(30, "minutes") });
|
||||
form.setFieldsValue({ end: start?.add(30, "minutes") });
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -77,6 +77,8 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
|
||||
.reduce((acc, val) => acc + val.mod_lb_hrs, 0);
|
||||
const ownerTitle = OwnerNameDisplayFunction(job).trim();
|
||||
|
||||
const employeeData = bodyshop.associations.find((a) => a.useremail === job.admin_clerk)?.user?.employee ?? null;
|
||||
|
||||
// Handle checkbox changes
|
||||
const handleCheckboxChange = async (field, checked) => {
|
||||
const value = checked ? dayjs().toISOString() : null;
|
||||
@@ -162,7 +164,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
|
||||
{job.cccontracts.map((c, index) => (
|
||||
<Space key={c.id} wrap>
|
||||
<Link to={`/manage/courtesycars/contracts/${c.id}`}>
|
||||
{`${c.agreementnumber} - ${c.courtesycar.fleetnumber} ${c.courtesycar.year} ${c.courtesycar.make} ${c.courtesycar.model}`}
|
||||
{`${c.agreementnumber} - ${c.courtesycar.fleetnumber} ${c.courtesycar.year} ${c.courtesycar.make} ${c.courtesycar.model} ${c.courtesycar.plate} - ${t(c.status)}`}
|
||||
{index !== job.cccontracts.length - 1 ? "," : null}
|
||||
</Link>
|
||||
</Space>
|
||||
@@ -355,6 +357,14 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
|
||||
>
|
||||
<div>
|
||||
<JobEmployeeAssignments job={job} />
|
||||
{job.admin_clerk && (
|
||||
<>
|
||||
<Divider style={{ margin: ".5rem" }} />
|
||||
<DataLabel label={t("jobs.fields.admin_clerk")}>
|
||||
{employeeData?.displayName ?? job.admin_clerk}
|
||||
</DataLabel>
|
||||
</>
|
||||
)}
|
||||
<Divider style={{ margin: ".5rem" }} />
|
||||
<DataLabel label={t("jobs.labels.labor_hrs")}>
|
||||
{bodyHrs.toFixed(1)} / {refinishHrs.toFixed(1)} / {(bodyHrs + refinishHrs).toFixed(1)}
|
||||
|
||||
@@ -35,16 +35,14 @@ export function JobsDocumentsImgproxyDownloadButton({ galleryImages, identifier,
|
||||
...galleryImages.other.filter((image) => image.isSelected)
|
||||
];
|
||||
|
||||
function downloadProgress(progressEvent) {
|
||||
setDownload((currentDownloadState) => {
|
||||
return {
|
||||
downloaded: progressEvent.loaded || 0,
|
||||
speed: (progressEvent.loaded || 0) - ((currentDownloadState && currentDownloadState.downloaded) || 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
const downloadProgress = ({ loaded }) => {
|
||||
setDownload((currentDownloadState) => ({
|
||||
downloaded: loaded ?? 0,
|
||||
speed: (loaded ?? 0) - (currentDownloadState?.downloaded ?? 0)
|
||||
}));
|
||||
};
|
||||
|
||||
function standardMediaDownload(bufferData) {
|
||||
const standardMediaDownload = (bufferData) => {
|
||||
try {
|
||||
const a = document.createElement("a");
|
||||
const url = window.URL.createObjectURL(new Blob([bufferData]));
|
||||
@@ -55,29 +53,26 @@ export function JobsDocumentsImgproxyDownloadButton({ galleryImages, identifier,
|
||||
setLoading(false);
|
||||
setDownload(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
logImEXEvent("jobs_documents_download");
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios({
|
||||
const { data } = await axios({
|
||||
url: "/media/imgproxy/download",
|
||||
method: "POST",
|
||||
responseType: "blob",
|
||||
data: { jobId, documentids: imagesToDownload.map((_) => _.id) },
|
||||
onDownloadProgress: downloadProgress
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
setDownload(null);
|
||||
|
||||
// Use the response data (Blob) to trigger download
|
||||
standardMediaDownload(response.data);
|
||||
standardMediaDownload(data);
|
||||
} catch {
|
||||
// handle error (optional)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setDownload(null);
|
||||
// handle error (optional)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -76,14 +76,14 @@ function JobsDocumentsImgproxyComponent({
|
||||
<SyncOutlined />
|
||||
</Button>
|
||||
<JobsDocumentsGallerySelectAllComponent galleryImages={galleryImages} setGalleryImages={setGalleryImages} />
|
||||
{!billId && (
|
||||
<JobsDocumentsGalleryReassign galleryImages={galleryImages} callback={fetchThumbnails || refetch} />
|
||||
)}
|
||||
<JobsDocumentsDownloadButton galleryImages={galleryImages} identifier={downloadIdentifier} jobId={jobId} />
|
||||
<JobsDocumentsDeleteButton
|
||||
galleryImages={galleryImages}
|
||||
deletionCallback={billsCallback || fetchThumbnails || refetch}
|
||||
/>
|
||||
{!billId && (
|
||||
<JobsDocumentsGalleryReassign galleryImages={galleryImages} callback={fetchThumbnails || refetch} />
|
||||
)}
|
||||
</Space>
|
||||
</Col>
|
||||
{!hasMediaAccess && (
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function JobsDocumentsImgproxyDeleteButton({ galleryImages, delet
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText={t("general.actions.cancel")}
|
||||
>
|
||||
<Button disabled={imagesToDelete.length < 1} loading={loading}>
|
||||
<Button danger disabled={imagesToDelete.length < 1} loading={loading}>
|
||||
{t("documents.actions.delete")}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
|
||||
@@ -107,8 +107,8 @@ export function JobsDocumentsLocalGallery({
|
||||
<a href={CreateExplorerLinkForJob({ jobid: job.id })}>
|
||||
<Button>{t("documents.labels.openinexplorer")}</Button>
|
||||
</a>
|
||||
<JobsDocumentsLocalGalleryReassign jobid={job.id} />
|
||||
<JobsDocumentsLocalGallerySelectAllComponent jobid={job.id} />
|
||||
<JobsDocumentsLocalGalleryReassign jobid={job.id} />
|
||||
<JobsLocalGalleryDownloadButton job={job} />
|
||||
<JobsDocumentsLocalDeleteButton jobid={job.id} />
|
||||
</Space>
|
||||
|
||||
@@ -28,6 +28,8 @@ export function JobsDocumentsLocalDeleteButton({ bodyshop, getJobMedia, allMedia
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const imagesToDelete = (allMedia?.[jobid] || []).filter((i) => i.isSelected);
|
||||
|
||||
const handleDelete = async () => {
|
||||
logImEXEvent("job_documents_delete");
|
||||
setLoading(true);
|
||||
@@ -36,7 +38,7 @@ export function JobsDocumentsLocalDeleteButton({ bodyshop, getJobMedia, allMedia
|
||||
`${bodyshop.localmediaserverhttp}/jobs/delete`,
|
||||
{
|
||||
jobid: jobid,
|
||||
files: (allMedia?.[jobid] || []).filter((i) => i.isSelected).map((i) => i.filename)
|
||||
files: imagesToDelete.map((i) => i.filename)
|
||||
},
|
||||
{ headers: { ims_token: bodyshop.localmediatoken } }
|
||||
);
|
||||
@@ -60,14 +62,17 @@ export function JobsDocumentsLocalDeleteButton({ bodyshop, getJobMedia, allMedia
|
||||
|
||||
return (
|
||||
<Popconfirm
|
||||
disabled={imagesToDelete.length < 1}
|
||||
icon={<QuestionCircleOutlined style={{ color: "red" }} />}
|
||||
onConfirm={handleDelete}
|
||||
title={t("documents.labels.confirmdelete")}
|
||||
okText={t("general.actions.delete")}
|
||||
okButtonProps={{ type: "danger" }}
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText={t("general.actions.cancel")}
|
||||
>
|
||||
<Button loading={loading}>{t("documents.actions.delete")}</Button>
|
||||
<Button danger disabled={imagesToDelete.length < 1} loading={loading}>
|
||||
{t("documents.actions.delete")}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Button } from "antd";
|
||||
import { Button, Space } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import cleanAxios from "../../utils/CleanAxios";
|
||||
|
||||
import formatBytes from "../../utils/formatbytes";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectAllMedia } from "../../redux/media/media.selectors";
|
||||
@@ -19,45 +19,63 @@ export default connect(mapStateToProps, mapDispatchToProps)(JobsLocalGalleryDown
|
||||
|
||||
export function JobsLocalGalleryDownloadButton({ bodyshop, allMedia, job }) {
|
||||
const { t } = useTranslation();
|
||||
const [download, setDownload] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [download, setDownload] = useState(false);
|
||||
|
||||
function downloadProgress(progressEvent) {
|
||||
setDownload((currentDownloadState) => {
|
||||
return {
|
||||
downloaded: progressEvent.loaded || 0,
|
||||
speed: (progressEvent.loaded || 0) - (currentDownloadState?.downloaded || 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
const imagesToDownload = (allMedia?.[job.id] || []).filter((i) => i.isSelected);
|
||||
|
||||
const downloadProgress = ({ loaded }) => {
|
||||
setDownload((currentDownloadState) => ({
|
||||
downloaded: loaded || 0,
|
||||
speed: (loaded || 0) - (currentDownloadState?.downloaded || 0)
|
||||
}));
|
||||
};
|
||||
|
||||
const standardMediaDownload = (bufferData, filename) => {
|
||||
try {
|
||||
const a = document.createElement("a");
|
||||
const url = window.URL.createObjectURL(new Blob([bufferData]));
|
||||
a.href = url;
|
||||
a.download = `${filename}.zip`;
|
||||
a.click();
|
||||
} catch {
|
||||
setLoading(false);
|
||||
setDownload(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
const theDownloadedZip = await cleanAxios.post(
|
||||
`${bodyshop.localmediaserverhttp}/jobs/download`,
|
||||
{
|
||||
jobid: job.id,
|
||||
files: (allMedia?.[job.id] || []).filter((i) => i.isSelected).map((i) => i.filename)
|
||||
},
|
||||
{
|
||||
headers: { ims_token: bodyshop.localmediatoken },
|
||||
responseType: "arraybuffer",
|
||||
onDownloadProgress: downloadProgress
|
||||
}
|
||||
);
|
||||
setDownload(null);
|
||||
standardMediaDownload(theDownloadedZip.data, job.ro_number);
|
||||
const { localmediaserverhttp, localmediatoken } = bodyshop;
|
||||
const { id, ro_number } = job;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await cleanAxios.post(
|
||||
`${localmediaserverhttp}/jobs/download`,
|
||||
{
|
||||
jobid: id,
|
||||
files: imagesToDownload.map((i) => i.filename)
|
||||
},
|
||||
{
|
||||
headers: { ims_token: localmediatoken },
|
||||
responseType: "arraybuffer",
|
||||
onDownloadProgress: downloadProgress
|
||||
}
|
||||
);
|
||||
standardMediaDownload(response.data, ro_number);
|
||||
} catch {
|
||||
// handle error (optional)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setDownload(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button loading={!!download} onClick={handleDownload}>
|
||||
{t("documents.actions.download")}
|
||||
<Button disabled={imagesToDownload < 1} loading={download || loading} onClick={handleDownload}>
|
||||
<Space>
|
||||
<span>{t("documents.actions.download")}</span>
|
||||
{download && <span>{`(${formatBytes(download.downloaded)} @ ${formatBytes(download.speed)} / second)`}</span>}
|
||||
</Space>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function standardMediaDownload(bufferData, filename) {
|
||||
const a = document.createElement("a");
|
||||
const url = window.URL.createObjectURL(new Blob([bufferData]));
|
||||
a.href = url;
|
||||
a.download = `${filename}.zip`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import { Gallery } from "react-grid-gallery";
|
||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import LocalMediaGrid from "./local-media-grid.component";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { getJobMedia } from "../../redux/media/media.actions";
|
||||
import { selectAllMedia } from "../../redux/media/media.selectors";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
@@ -18,41 +19,127 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(JobDocumentsLocalGalleryExternal);
|
||||
|
||||
function JobDocumentsLocalGalleryExternal({ jobId, externalMediaState, getJobMedia, allMedia }) {
|
||||
/**
|
||||
* JobDocumentsLocalGalleryExternal
|
||||
* Fetches and displays job-related image media using the custom LocalMediaGrid.
|
||||
*
|
||||
* Props:
|
||||
* - jobId: string | number (required to fetch media)
|
||||
* - externalMediaState: [imagesArray, setImagesFn] (state lifted to parent for shared selection)
|
||||
* - getJobMedia: dispatching function to retrieve media for a job
|
||||
* - allMedia: redux slice keyed by jobId containing raw media records
|
||||
* - context: "chat" | "email" | other string used to drive grid behavior
|
||||
*
|
||||
* Notes:
|
||||
* - The previous third-party gallery required a remount key (openVersion); custom grid no longer does.
|
||||
* - Selection flags are preserved when media refreshes.
|
||||
* - Loading state ends after transformation regardless of whether any images were found.
|
||||
*/
|
||||
function JobDocumentsLocalGalleryExternal({ jobId, externalMediaState, getJobMedia, allMedia, context = "chat" }) {
|
||||
const [galleryImages, setgalleryImages] = externalMediaState;
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { t } = useTranslation(); // i18n hook retained if future translations are added
|
||||
const DEBUG_LOCAL_GALLERY = false; // flip to true for verbose console logging
|
||||
|
||||
// Transform raw media record into a normalized image object consumed by the grid.
|
||||
const transformMediaToImages = useCallback((raw) => {
|
||||
return raw
|
||||
.filter((m) => m.type?.mime?.startsWith("image"))
|
||||
.map((m) => ({
|
||||
...m,
|
||||
src: m.thumbnail,
|
||||
thumbnail: m.thumbnail,
|
||||
fullsize: m.src,
|
||||
width: 225,
|
||||
height: 225,
|
||||
thumbnailWidth: 225,
|
||||
thumbnailHeight: 225,
|
||||
caption: m.filename || m.key
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Fetch media when jobId changes (network request triggers Redux update -> documents memo recalculates).
|
||||
useEffect(() => {
|
||||
if (jobId) {
|
||||
getJobMedia(jobId);
|
||||
}
|
||||
if (!jobId) return;
|
||||
setIsLoading(true);
|
||||
getJobMedia(jobId);
|
||||
}, [jobId, getJobMedia]);
|
||||
|
||||
useEffect(() => {
|
||||
let documents = allMedia?.[jobId]
|
||||
? allMedia[jobId].reduce((acc, val) => {
|
||||
if (val.type?.mime && val.type.mime.startsWith("image")) {
|
||||
acc.push({ ...val, src: val.thumbnail, fullsize: val.src });
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
: [];
|
||||
console.log(
|
||||
"🚀 ~ file: jobs-documents-local-gallery.external.component.jsx:48 ~ useEffect ~ documents:",
|
||||
documents
|
||||
);
|
||||
// Memo: transform raw redux media into gallery documents.
|
||||
const documents = useMemo(
|
||||
() => transformMediaToImages(allMedia?.[jobId] || []),
|
||||
[allMedia, jobId, transformMediaToImages]
|
||||
);
|
||||
|
||||
setgalleryImages(documents);
|
||||
}, [allMedia, jobId, setgalleryImages, t]);
|
||||
// Sync transformed documents into external state while preserving selection flags.
|
||||
useEffect(() => {
|
||||
const prevSelection = new Map(galleryImages.map((p) => [p.filename, p.isSelected]));
|
||||
const nextImages = documents.map((d) => ({ ...d, isSelected: prevSelection.get(d.filename) || false }));
|
||||
// Micro-optimization: if array length and each filename + selection flag match, skip creating a new array.
|
||||
if (galleryImages.length === nextImages.length) {
|
||||
let identical = true;
|
||||
for (let i = 0; i < nextImages.length; i++) {
|
||||
if (
|
||||
galleryImages[i].filename !== nextImages[i].filename ||
|
||||
galleryImages[i].isSelected !== nextImages[i].isSelected
|
||||
) {
|
||||
identical = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (identical) {
|
||||
setIsLoading(false); // ensure loading stops even on no-change
|
||||
if (DEBUG_LOCAL_GALLERY) {
|
||||
console.log("[LocalGallery] documents unchanged", { jobId, count: documents.length });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
setgalleryImages(nextImages);
|
||||
setIsLoading(false); // stop loading after transform regardless of emptiness
|
||||
if (DEBUG_LOCAL_GALLERY) {
|
||||
console.log("[LocalGallery] documents transformed", { jobId, count: documents.length });
|
||||
}
|
||||
}, [documents, setgalleryImages, galleryImages, jobId, DEBUG_LOCAL_GALLERY]);
|
||||
|
||||
// Toggle handler (stable reference)
|
||||
const handleToggle = useCallback(
|
||||
(idx) => {
|
||||
setgalleryImages((imgs) => imgs.map((g, gIdx) => (gIdx === idx ? { ...g, isSelected: !g.isSelected } : g)));
|
||||
},
|
||||
[setgalleryImages]
|
||||
);
|
||||
|
||||
const messageStyle = { textAlign: "center", padding: "1rem" }; // retained for potential future states
|
||||
|
||||
if (!jobId) {
|
||||
return (
|
||||
<div aria-label="media gallery unavailable" style={{ position: "relative", minHeight: 80 }}>
|
||||
<div style={messageStyle}>No job selected.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="clearfix">
|
||||
<Gallery
|
||||
images={galleryImages}
|
||||
onSelect={(index) => {
|
||||
setgalleryImages(galleryImages.map((g, idx) => (index === idx ? { ...g, isSelected: !g.isSelected } : g)));
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="clearfix"
|
||||
style={{ position: "relative", minHeight: 80 }}
|
||||
data-jobid={jobId}
|
||||
aria-label={`media gallery for job ${jobId}`}
|
||||
>
|
||||
{isLoading && galleryImages.length === 0 && (
|
||||
<div className="local-gallery-loading" style={messageStyle} role="status" aria-live="polite">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
{galleryImages.length > 0 && (
|
||||
<LocalMediaGrid images={galleryImages} minColumns={4} context={context} onToggle={handleToggle} />
|
||||
)}
|
||||
{galleryImages.length > 0 && (
|
||||
<div style={{ fontSize: 10, color: "#888", marginTop: 4 }} aria-live="off">
|
||||
{`${t("general.labels.media")}: ${galleryImages.length}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* LocalMediaGrid
|
||||
* Lightweight replacement for react-grid-gallery inside the chat popover.
|
||||
* Props:
|
||||
* - images: Array<{ src, fullsize, filename?, isSelected? }>
|
||||
* - onToggle(index)
|
||||
*/
|
||||
export function LocalMediaGrid({
|
||||
images,
|
||||
onToggle,
|
||||
thumbSize = 100,
|
||||
gap = 8,
|
||||
minColumns = 3,
|
||||
maxColumns = 12,
|
||||
context = "default"
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const [cols, setCols] = useState(() => {
|
||||
// Pre-calc initial columns to stabilize layout before images render
|
||||
const count = images.length;
|
||||
if (count === 0) return minColumns; // reserve minimal structure
|
||||
if (count === 1 && context === "chat") return 1;
|
||||
return Math.min(maxColumns, Math.max(minColumns, count));
|
||||
});
|
||||
const [justifyMode, setJustifyMode] = useState("start");
|
||||
const [distributeExtra, setDistributeExtra] = useState(false);
|
||||
const [loadedMap, setLoadedMap] = useState(() => new Map()); // filename -> boolean loaded
|
||||
|
||||
const handleImageLoad = useCallback((key) => {
|
||||
setLoadedMap((prev) => {
|
||||
if (prev.get(key)) return prev; // already loaded
|
||||
const next = new Map(prev);
|
||||
next.set(key, true);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Dynamically compute columns for all contexts to avoid auto-fit stretching gaps in email overlay
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const compute = () => {
|
||||
// For non-chat (email / default) we rely on CSS auto-fill; only chat needs explicit column calc & distribution logic.
|
||||
if (context !== "chat") {
|
||||
setCols(images.length || 0); // retain count for ARIA semantics; not used for template when non-chat.
|
||||
setDistributeExtra(false);
|
||||
return;
|
||||
}
|
||||
const width = el.clientWidth;
|
||||
if (!width) return;
|
||||
const perCol = thumbSize + gap; // track + gap space
|
||||
const fitCols = Math.max(1, Math.floor((width + gap) / perCol));
|
||||
// base desired columns: up to how many images we have and how many fit
|
||||
let finalCols = Math.min(images.length || 1, fitCols, maxColumns);
|
||||
// enforce minimum columns to reserve layout skeleton (except when fewer images)
|
||||
if (finalCols < minColumns && images.length >= minColumns) {
|
||||
finalCols = Math.min(fitCols, minColumns);
|
||||
}
|
||||
// chat-specific clamp
|
||||
if (context === "chat") {
|
||||
finalCols = Math.min(finalCols, 4);
|
||||
}
|
||||
if (finalCols < 1) finalCols = 1;
|
||||
setCols(finalCols);
|
||||
setJustifyMode("start");
|
||||
|
||||
// Determine if there is leftover horizontal space that can't fit another column.
|
||||
// Only distribute when we're at the maximum allowed columns for the context and images exceed or meet that count.
|
||||
const contextMax = context === "chat" ? 4 : maxColumns;
|
||||
const baseWidthNeeded = finalCols * thumbSize + (finalCols - 1) * gap;
|
||||
const leftover = width - baseWidthNeeded;
|
||||
const atMaxColumns = finalCols === contextMax && images.length >= finalCols;
|
||||
// leftover must be positive but less than space needed for an additional column (perCol)
|
||||
if (atMaxColumns && leftover > 0 && leftover < perCol) {
|
||||
setDistributeExtra(true);
|
||||
} else {
|
||||
setDistributeExtra(false);
|
||||
}
|
||||
};
|
||||
compute();
|
||||
const ro = new ResizeObserver(() => compute());
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [images.length, thumbSize, gap, minColumns, maxColumns, context]);
|
||||
|
||||
const gridTemplateColumns = useMemo(() => {
|
||||
if (context === "chat") {
|
||||
if (distributeExtra) {
|
||||
return `repeat(${cols}, minmax(${thumbSize}px, 1fr))`;
|
||||
}
|
||||
return `repeat(${cols}, ${thumbSize}px)`;
|
||||
}
|
||||
// Non-chat contexts: allow browser to auto-fill columns; fixed min (thumbSize) ensures squares; tracks expand to distribute remaining space.
|
||||
return `repeat(auto-fill, minmax(${thumbSize}px, 1fr))`;
|
||||
}, [cols, thumbSize, distributeExtra, context]);
|
||||
const stableWidth = undefined; // no fixed width
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e, idx) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onToggle(idx);
|
||||
}
|
||||
},
|
||||
[onToggle]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="local-media-grid"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns,
|
||||
gap,
|
||||
maxHeight: 420,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: 4,
|
||||
justifyContent: justifyMode,
|
||||
width: stableWidth
|
||||
}}
|
||||
ref={containerRef}
|
||||
role="list"
|
||||
aria-label="media thumbnails"
|
||||
>
|
||||
{images.map((img, idx) => (
|
||||
<div
|
||||
key={img.filename || idx}
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
aria-label={img.filename || `image ${idx + 1}`}
|
||||
onClick={() => onToggle(idx)}
|
||||
onKeyDown={(e) => handleKeyDown(e, idx)}
|
||||
style={{
|
||||
position: "relative",
|
||||
border: img.isSelected ? "2px solid #1890ff" : "1px solid #ccc",
|
||||
outline: "none",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
background: "#fafafa",
|
||||
width: thumbSize,
|
||||
height: thumbSize,
|
||||
overflow: "hidden",
|
||||
boxSizing: "border-box"
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const key = img.filename || idx;
|
||||
const loaded = loadedMap.get(key) === true;
|
||||
return (
|
||||
<>
|
||||
{!loaded && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "#f0f0f0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 10,
|
||||
color: "#bbb"
|
||||
}}
|
||||
>
|
||||
{/* simple skeleton; no shimmer to reduce cost */}…
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={img.src}
|
||||
alt={img.filename || img.caption || "thumbnail"}
|
||||
loading="lazy"
|
||||
onLoad={() => handleImageLoad(key)}
|
||||
style={{
|
||||
width: thumbSize,
|
||||
height: thumbSize,
|
||||
objectFit: "cover",
|
||||
display: "block",
|
||||
borderRadius: 4,
|
||||
opacity: loaded ? 1 : 0,
|
||||
transition: "opacity .25s ease"
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{img.isSelected && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "rgba(24,144,255,0.45)",
|
||||
borderRadius: 4
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* No placeholders needed; layout uses auto-fit for non-chat or fixed columns for chat */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LocalMediaGrid;
|
||||
@@ -144,7 +144,7 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
|
||||
<Spin spinning={loading}>
|
||||
{record[type] ? (
|
||||
<div>
|
||||
<span>{`${theEmployee.first_name || ""} ${theEmployee.last_name || ""}`}</span>
|
||||
<span>{`${theEmployee?.first_name || ""} ${theEmployee?.last_name || ""}`}</span>
|
||||
<DeleteFilled style={iconStyle} onClick={() => handleRemove(type)} />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -143,7 +143,7 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
|
||||
|
||||
//TODO: Find a way to filter out / blur on demand.
|
||||
return (
|
||||
<div>
|
||||
<div className="report-center-modal">
|
||||
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
|
||||
<Input.Search onChange={(e) => setSearch(e.target.value)} value={search} />
|
||||
<Form.Item name="defaultSorters" hidden />
|
||||
@@ -163,13 +163,14 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
|
||||
{Object.keys(grouped)
|
||||
//.filter((key) => !groupExcludeKeyFilter.includes(key))
|
||||
.map((key) => (
|
||||
<Col md={8} sm={12} key={key}>
|
||||
<Col xs={24} sm={12} md={Object.keys(grouped).length === 1 ? 24 : 8} key={key}>
|
||||
<Card.Grid
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
maxHeight: "33vh",
|
||||
overflowY: "scroll"
|
||||
overflowY: "scroll",
|
||||
minWidth: "200px"
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4}>{t(`reportcenter.labels.groups.${key}`)}</Typography.Title>
|
||||
@@ -177,7 +178,7 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
|
||||
<BlurWrapperComponent
|
||||
featureName={groupExcludeKeyFilter.find((g) => g.key === key).featureName}
|
||||
>
|
||||
<ul style={{ listStyleType: "none", columns: "2 auto" }}>
|
||||
<ul style={{ listStyleType: "none", columns: grouped[key].length > 4 ? "2 auto" : "1", padding: 0, margin: 0 }}>
|
||||
{grouped[key].map((item) => (
|
||||
<li key={item.key}>
|
||||
<Radio key={item.key} value={item.key}>
|
||||
@@ -188,7 +189,7 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
|
||||
</ul>
|
||||
</BlurWrapperComponent>
|
||||
) : (
|
||||
<ul style={{ listStyleType: "none", columns: "2 auto" }}>
|
||||
<ul style={{ listStyleType: "none", columns: grouped[key].length > 4 ? "2 auto" : "1", padding: 0, margin: 0 }}>
|
||||
{grouped[key].map((item) =>
|
||||
item.featureNameRestricted ? (
|
||||
<li key={item.key}>
|
||||
|
||||
@@ -11,3 +11,38 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Report center modal fixes for column layout
|
||||
.report-center-modal {
|
||||
.ant-form-item .ant-radio-group {
|
||||
width: 100%;
|
||||
|
||||
.ant-card-grid {
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
|
||||
ul {
|
||||
width: 100%;
|
||||
|
||||
li {
|
||||
margin-bottom: 8px;
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
|
||||
.ant-radio-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
|
||||
span:not(.ant-radio) {
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,7 +425,15 @@ export function ShopInfoGeneral({ form, bodyshop }) {
|
||||
]
|
||||
: [])
|
||||
]
|
||||
: [])
|
||||
: []),
|
||||
<Form.Item
|
||||
key="accumulatePayableLines"
|
||||
name={["accountingconfig", "accumulatePayableLines"]}
|
||||
label={t("bodyshop.fields.accumulatePayableLines")}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
]}
|
||||
</LayoutFormRow>
|
||||
<FeatureWrapper featureName="scoreboard" noauth={() => null}>
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function ShopInfoNotificationsAutoadd({ bodyshop }) {
|
||||
<Text type="secondary">{t("bodyshop.labels.notifications.followers")}</Text>
|
||||
{employeeOptions.length > 0 ? (
|
||||
<Form.Item
|
||||
normalize={(value) => (value || []).filter((id) => typeof id === "string" && id.trim() !== "")}
|
||||
name="notification_followers"
|
||||
rules={[
|
||||
{
|
||||
@@ -42,11 +43,6 @@ export default function ShopInfoNotificationsAutoadd({ bodyshop }) {
|
||||
options={employeeOptions}
|
||||
placeholder={t("bodyshop.fields.notifications.placeholder")}
|
||||
showEmail={true}
|
||||
onChange={(value) => {
|
||||
// Filter out null or invalid values before passing to Form
|
||||
const cleanedValue = value?.filter((id) => id != null && typeof id === "string" && id.trim() !== "");
|
||||
return cleanedValue;
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
|
||||
@@ -4,10 +4,18 @@ import { useTranslation } from "react-i18next";
|
||||
import { TemplateList } from "../../utils/TemplateConstants";
|
||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
|
||||
export default function ShopInfoSpeedPrint() {
|
||||
const { t } = useTranslation();
|
||||
const TemplateListGenerated = TemplateList("job");
|
||||
const allTemplates = TemplateList("job");
|
||||
const TemplateListGenerated = InstanceRenderManager({
|
||||
imex: Object.fromEntries(
|
||||
Object.entries(allTemplates).filter(([, { enhanced_payroll }]) => !enhanced_payroll)
|
||||
),
|
||||
rome: allTemplates
|
||||
});
|
||||
|
||||
return (
|
||||
<Form.List name={["speedprint"]}>
|
||||
{(fields, { add, remove, move }) => {
|
||||
|
||||
@@ -16,7 +16,7 @@ const mapDispatchToProps = () => ({
|
||||
export function TechHeader({ technician }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Header style={{ textAlign: "center" }}>
|
||||
<Header style={{ textAlign: "center", height: "auto", overflow: "visible" }}>
|
||||
<Typography.Title style={{ color: "#fff" }}>
|
||||
{technician
|
||||
? t("tech.labels.loggedin", {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { Button, Card, Col, Form, InputNumber, Popover, Row, Select } from "antd";
|
||||
import { Button, Card, Form, InputNumber, Popover, Select, Space } from "antd";
|
||||
import axios from "axios";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -124,103 +124,12 @@ export function TechClockOffButton({
|
||||
cost_center: isShiftTicket ? "timetickets.labels.shift" : technician ? technician.cost_center : null
|
||||
}}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={!isShiftTicket ? 8 : 24}>
|
||||
{!isShiftTicket ? (
|
||||
<div>
|
||||
<Form.Item
|
||||
label={t("timetickets.fields.actualhrs")}
|
||||
name="actualhrs"
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0} precision={1} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("timetickets.fields.productivehrs")}
|
||||
name="productivehrs"
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
},
|
||||
({ getFieldValue }) => ({
|
||||
validator(rule, value) {
|
||||
if (!bodyshop.tt_enforce_hours_for_tech_console) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!value || getFieldValue("cost_center") === null || !lineTicketData)
|
||||
return Promise.resolve();
|
||||
|
||||
//Check the cost center,
|
||||
const totals = CalculateAllocationsTotals(
|
||||
bodyshop,
|
||||
lineTicketData.joblines,
|
||||
lineTicketData.timetickets,
|
||||
lineTicketData.jobs_by_pk.lbr_adjustments
|
||||
);
|
||||
|
||||
const fieldTypeToCheck =
|
||||
bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber ? "mod_lbr_ty" : "cost_center";
|
||||
|
||||
const costCenterDiff =
|
||||
Math.round(
|
||||
totals.find((total) => total[fieldTypeToCheck] === getFieldValue("cost_center"))
|
||||
?.difference * 10
|
||||
) / 10;
|
||||
|
||||
if (value > costCenterDiff)
|
||||
return Promise.reject(t("timetickets.validation.hoursenteredmorethanavailable"));
|
||||
else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
})
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0} precision={1} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
) : null}
|
||||
<Form.Item
|
||||
name="cost_center"
|
||||
label={t("timetickets.fields.cost_center")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select disabled={isShiftTicket}>
|
||||
{isShiftTicket ? (
|
||||
<Select.Option value="timetickets.labels.shift">{t("timetickets.labels.shift")}</Select.Option>
|
||||
) : (
|
||||
emps &&
|
||||
emps.rates.map((item) => (
|
||||
<Select.Option key={item.cost_center}>
|
||||
{item.cost_center === "timetickets.labels.shift"
|
||||
? t(item.cost_center)
|
||||
: bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber
|
||||
? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`)
|
||||
: item.cost_center}
|
||||
</Select.Option>
|
||||
))
|
||||
)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{isShiftTicket ? (
|
||||
<div></div>
|
||||
) : (
|
||||
<Space direction="vertical">
|
||||
{!isShiftTicket ? (
|
||||
<div>
|
||||
<Form.Item
|
||||
name="status"
|
||||
label={t("jobs.fields.status")}
|
||||
initialValue={lineTicketData && lineTicketData.jobs_by_pk.status}
|
||||
label={t("timetickets.fields.actualhrs")}
|
||||
name="actualhrs"
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
@@ -228,35 +137,117 @@ export function TechClockOffButton({
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
{bodyshop.md_ro_statuses.production_statuses.map((item) => (
|
||||
<Select.Option key={item}></Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<InputNumber min={0} precision={1} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
{t("general.actions.save")}
|
||||
</Button>
|
||||
<TechJobClockoutDelete completedCallback={completedCallback} timeTicketId={timeTicketId} />
|
||||
</Col>
|
||||
{!isShiftTicket && (
|
||||
<Col span={16}>
|
||||
<LaborAllocationContainer
|
||||
jobid={jobId || null}
|
||||
loading={queryLoading}
|
||||
lineTicketData={lineTicketData}
|
||||
/>
|
||||
</Col>
|
||||
<Form.Item
|
||||
label={t("timetickets.fields.productivehrs")}
|
||||
name="productivehrs"
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
},
|
||||
({ getFieldValue }) => ({
|
||||
validator(rule, value) {
|
||||
if (!bodyshop.tt_enforce_hours_for_tech_console) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!value || getFieldValue("cost_center") === null || !lineTicketData)
|
||||
return Promise.resolve();
|
||||
//Check the cost center,
|
||||
const totals = CalculateAllocationsTotals(
|
||||
bodyshop,
|
||||
lineTicketData.joblines,
|
||||
lineTicketData.timetickets,
|
||||
lineTicketData.jobs_by_pk.lbr_adjustments
|
||||
);
|
||||
const fieldTypeToCheck =
|
||||
bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber ? "mod_lbr_ty" : "cost_center";
|
||||
const costCenterDiff =
|
||||
Math.round(
|
||||
totals.find((total) => total[fieldTypeToCheck] === getFieldValue("cost_center"))
|
||||
?.difference * 10
|
||||
) / 10;
|
||||
if (value > costCenterDiff)
|
||||
return Promise.reject(t("timetickets.validation.hoursenteredmorethanavailable"));
|
||||
else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
})
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0} precision={1} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
) : null}
|
||||
<Form.Item
|
||||
name="cost_center"
|
||||
label={t("timetickets.fields.cost_center")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select disabled={isShiftTicket}>
|
||||
{isShiftTicket ? (
|
||||
<Select.Option value="timetickets.labels.shift">{t("timetickets.labels.shift")}</Select.Option>
|
||||
) : (
|
||||
emps &&
|
||||
emps.rates.map((item) => (
|
||||
<Select.Option key={item.cost_center}>
|
||||
{item.cost_center === "timetickets.labels.shift"
|
||||
? t(item.cost_center)
|
||||
: bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber
|
||||
? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`)
|
||||
: item.cost_center}
|
||||
</Select.Option>
|
||||
))
|
||||
)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{isShiftTicket ? (
|
||||
<div></div>
|
||||
) : (
|
||||
<Form.Item
|
||||
name="status"
|
||||
label={t("jobs.fields.status")}
|
||||
initialValue={lineTicketData && lineTicketData.jobs_by_pk.status}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
{bodyshop.md_ro_statuses.production_statuses.map((item) => (
|
||||
<Select.Option key={item}></Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Row>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
{t("general.actions.save")}
|
||||
</Button>
|
||||
<TechJobClockoutDelete completedCallback={completedCallback} timeTicketId={timeTicketId} />
|
||||
{!isShiftTicket && (
|
||||
<LaborAllocationContainer jobid={jobId || null} loading={queryLoading} lineTicketData={lineTicketData} />
|
||||
)}
|
||||
</Space>
|
||||
</Form>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={overlay} trigger="click">
|
||||
<Popover
|
||||
content={<div style={{ maxHeight: "75vh", overflowY: "auto" }}>{overlay}</div>}
|
||||
trigger="click"
|
||||
getPopupContainer={() => document.querySelector('#time-ticket-modal')}
|
||||
>
|
||||
<Button loading={loading} {...otherBtnProps}>
|
||||
{t("timetickets.actions.clockout")}
|
||||
</Button>
|
||||
|
||||
@@ -4,8 +4,8 @@ import { getAuth, updatePassword, updateProfile } from "@firebase/auth";
|
||||
import { getFirestore } from "@firebase/firestore";
|
||||
import { getMessaging, getToken, onMessage } from "@firebase/messaging";
|
||||
import { store } from "../redux/store";
|
||||
import * as amplitude from '@amplitude/analytics-browser';
|
||||
import posthog from 'posthog-js'
|
||||
//import * as amplitude from '@amplitude/analytics-browser';
|
||||
// import posthog from 'posthog-js'
|
||||
|
||||
const config = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
|
||||
initializeApp(config);
|
||||
@@ -74,7 +74,6 @@ onMessage(messaging, (payload) => {
|
||||
|
||||
export const logImEXEvent = (eventName, additionalParams, stateProp = null) => {
|
||||
try {
|
||||
|
||||
const state = stateProp || store.getState();
|
||||
|
||||
const eventParams = {
|
||||
@@ -91,16 +90,15 @@ export const logImEXEvent = (eventName, additionalParams, stateProp = null) => {
|
||||
// dbevent: false,
|
||||
// env: `master-AIO|${import.meta.env.VITE_APP_GIT_SHA_DATE}`
|
||||
// });
|
||||
console.log(
|
||||
"%c[Analytics]",
|
||||
"background-color: green ;font-weight:bold;",
|
||||
eventName,
|
||||
eventParams
|
||||
);
|
||||
// console.log(
|
||||
// "%c[Analytics]",
|
||||
// "background-color: green ;font-weight:bold;",
|
||||
// eventName,
|
||||
// eventParams
|
||||
// );
|
||||
logEvent(analytics, eventName, eventParams);
|
||||
amplitude.track(eventName, eventParams);
|
||||
posthog.capture(eventName, eventParams);
|
||||
|
||||
//amplitude.track(eventName, eventParams);
|
||||
//posthog.capture(eventName, eventParams);
|
||||
} finally {
|
||||
//If it fails, just keep going.
|
||||
}
|
||||
|
||||
@@ -363,3 +363,25 @@ export const GET_ACTIVE_EMPLOYEES_IN_SHOP = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const QUERY_MINIMAL_BODYSHOP = gql`
|
||||
query QUERY_MINIMAL_BODYSHOP {
|
||||
bodyshops(where: { associations: { active: { _eq: true } } }) {
|
||||
id
|
||||
shopname
|
||||
associations(where: { active: { _eq: true } }) {
|
||||
user {
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const QUERY_DASHBOARD_BODYSHOP = gql`
|
||||
query QUERY_DASHBOARD_BODYSHOP {
|
||||
dashboard_bodyshops: bodyshops(where: { associations: { active: { _eq: true } } }) {
|
||||
id
|
||||
prodtargethrs
|
||||
md_ro_statuses
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -424,6 +424,7 @@ export const GET_JOB_BY_PK = gql`
|
||||
actual_delivery
|
||||
actual_in
|
||||
acv_amount
|
||||
admin_clerk
|
||||
adjustment_bottom_line
|
||||
alt_transport
|
||||
area_of_damage
|
||||
@@ -2347,12 +2348,13 @@ export const MARK_JOB_AS_UNINVOICED = gql`
|
||||
mutation MARK_JOB_AS_UNINVOICED($jobId: uuid!, $default_delivered: String!) {
|
||||
update_jobs_by_pk(
|
||||
pk_columns: { id: $jobId }
|
||||
_set: { date_exported: null, date_invoiced: null, status: $default_delivered }
|
||||
_set: { date_exported: null, date_invoiced: null, status: $default_delivered, admin_clerk: null }
|
||||
) {
|
||||
id
|
||||
date_exported
|
||||
date_invoiced
|
||||
status
|
||||
admin_clerk
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -135,3 +135,12 @@ export const UPDATE_NOTIFICATIONS_AUTOADD = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const QUERY_USER_DASHBOARD_LAYOUT = gql`
|
||||
query QUERY_USER_DASHBOARD_LAYOUT($email: String!) {
|
||||
users(where: { email: { _eq: $email } }) {
|
||||
email
|
||||
dashboardlayout
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { persistor, store } from "./redux/store";
|
||||
import reportWebVitals from "./reportWebVitals";
|
||||
import "./translations/i18n";
|
||||
import "./utils/CleanAxios";
|
||||
import * as amplitude from "@amplitude/analytics-browser";
|
||||
//import * as amplitude from "@amplitude/analytics-browser";
|
||||
import { PostHogProvider } from "posthog-js/react";
|
||||
import posthog from "posthog-js";
|
||||
|
||||
@@ -26,23 +26,23 @@ registerSW({ immediate: true });
|
||||
// Dinero.globalLocale = "en-CA";
|
||||
Dinero.globalRoundingMode = "HALF_EVEN";
|
||||
|
||||
amplitude.init(import.meta.env.VITE_APP_AMP_KEY, {
|
||||
defaultTracking: true,
|
||||
serverUrl: import.meta.env.VITE_APP_AMP_URL
|
||||
// {
|
||||
// attribution: {
|
||||
// excludeReferrers: true,
|
||||
// initialEmptyValue: true,
|
||||
// resetSessionOnNewCampaign: true,
|
||||
// },
|
||||
// fileDownloads: true,
|
||||
// formInteractions: true,
|
||||
// pageViews: {
|
||||
// trackHistoryChanges: 'all'
|
||||
// },
|
||||
// sessions: true
|
||||
// }
|
||||
});
|
||||
// amplitude.init(import.meta.env.VITE_APP_AMP_KEY, {
|
||||
// defaultTracking: true,
|
||||
// serverUrl: import.meta.env.VITE_APP_AMP_URL
|
||||
// // {
|
||||
// // attribution: {
|
||||
// // excludeReferrers: true,
|
||||
// // initialEmptyValue: true,
|
||||
// // resetSessionOnNewCampaign: true,
|
||||
// // },
|
||||
// // fileDownloads: true,
|
||||
// // formInteractions: true,
|
||||
// // pageViews: {
|
||||
// // trackHistoryChanges: 'all'
|
||||
// // },
|
||||
// // sessions: true
|
||||
// // }
|
||||
// });
|
||||
|
||||
posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, {
|
||||
autocapture: false,
|
||||
|
||||
@@ -39,13 +39,14 @@ import { UPDATE_JOB } from "../../graphql/jobs.queries";
|
||||
import { insertAuditTrail } from "../../redux/application/application.actions";
|
||||
import { selectJobReadOnly } from "../../redux/application/application.selectors";
|
||||
import { setModalContext } from "../../redux/modals/modals.actions.js";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
||||
import dayjs from "../../utils/day";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
jobRO: selectJobReadOnly
|
||||
jobRO: selectJobReadOnly,
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
@@ -59,7 +60,7 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
)
|
||||
});
|
||||
|
||||
export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, setPrintCenterContext }) {
|
||||
export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, setPrintCenterContext, currentUser }) {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const client = useApolloClient();
|
||||
@@ -97,6 +98,7 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
|
||||
kmin: values.kmin,
|
||||
kmout: values.kmout,
|
||||
dms_allocation: values.dms_allocation,
|
||||
admin_clerk: currentUser.email,
|
||||
...(removefromproduction ? { inproduction: false } : {}),
|
||||
...(values.qb_multiple_payers ? { qb_multiple_payers: values.qb_multiple_payers } : {})
|
||||
}
|
||||
|
||||
@@ -19,29 +19,19 @@ const mediaReducer = (state = INITIAL_STATE, action) => {
|
||||
case MediaActionTypes.TOGGLE_MEDIA_SELECTED:
|
||||
return {
|
||||
...state,
|
||||
[action.payload.jobid]: state[action.payload.jobid].map((p) => {
|
||||
if (p.filename === action.payload.filename) {
|
||||
p.isSelected = !p.isSelected;
|
||||
}
|
||||
return p;
|
||||
})
|
||||
[action.payload.jobid]: state[action.payload.jobid].map((p) =>
|
||||
p.filename === action.payload.filename ? { ...p, isSelected: !p.isSelected } : p
|
||||
)
|
||||
};
|
||||
case MediaActionTypes.SELECT_ALL_MEDIA_FOR_JOB:
|
||||
return {
|
||||
...state,
|
||||
[action.payload.jobid]: state[action.payload.jobid].map((p) => {
|
||||
p.isSelected = true;
|
||||
|
||||
return p;
|
||||
})
|
||||
[action.payload.jobid]: state[action.payload.jobid].map((p) => ({ ...p, isSelected: true }))
|
||||
};
|
||||
case MediaActionTypes.DESELECT_ALL_MEDIA_FOR_JOB:
|
||||
return {
|
||||
...state,
|
||||
[action.payload.jobid]: state[action.payload.jobid].map((p) => {
|
||||
p.isSelected = false;
|
||||
return p;
|
||||
})
|
||||
[action.payload.jobid]: state[action.payload.jobid].map((p) => ({ ...p, isSelected: false }))
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
|
||||
@@ -17,9 +17,10 @@ export function* getJobMedia({ payload: jobid }) {
|
||||
const imagesFetch = yield cleanAxios.post(
|
||||
`${localmediaserverhttp}/jobs/list`,
|
||||
{
|
||||
jobid
|
||||
jobid,
|
||||
|
||||
},
|
||||
{ headers: { ims_token: bodyshop.localmediatoken } }
|
||||
{ headers: { ims_token: bodyshop.localmediatoken, bodyshopid: bodyshop.id } }
|
||||
);
|
||||
const documentsFetch = yield cleanAxios.post(
|
||||
`${localmediaserverhttp}/bills/list`,
|
||||
|
||||
@@ -2,4 +2,5 @@ import { createSelector } from "reselect";
|
||||
|
||||
const selectMedia = (state) => state.media;
|
||||
|
||||
export const selectAllMedia = createSelector([selectMedia], (media) => media);
|
||||
// Return a shallow copy to avoid identity selector warning and allow memoization to detect actual changes.
|
||||
export const selectAllMedia = createSelector([selectMedia], (media) => ({ ...media }));
|
||||
|
||||
@@ -49,8 +49,8 @@ import {
|
||||
validatePasswordResetSuccess
|
||||
} from "./user.actions";
|
||||
import UserActionTypes from "./user.types";
|
||||
import * as amplitude from '@amplitude/analytics-browser';
|
||||
import posthog from 'posthog-js';
|
||||
//import * as amplitude from '@amplitude/analytics-browser';
|
||||
import posthog from "posthog-js";
|
||||
|
||||
const fpPromise = FingerprintJS.load();
|
||||
|
||||
@@ -92,7 +92,7 @@ export function* isUserAuthenticated() {
|
||||
}
|
||||
|
||||
LogRocket.identify(user.email);
|
||||
amplitude.setUserId(user.email);
|
||||
//amplitude.setUserId(user.email);
|
||||
posthog.identify(user.email);
|
||||
|
||||
const eulaQuery = yield client.query({
|
||||
@@ -139,7 +139,7 @@ export function* signOutStart() {
|
||||
imexshopid: state.user.bodyshop.imexshopid,
|
||||
type: "messaging"
|
||||
});
|
||||
amplitude.reset();
|
||||
//amplitude.reset();
|
||||
} catch {
|
||||
console.log("No FCM token. Skipping unsubscribe.");
|
||||
}
|
||||
@@ -269,11 +269,11 @@ export function* signInSuccessSaga({ payload }) {
|
||||
instanceSeg,
|
||||
...(isParts
|
||||
? [
|
||||
InstanceRenderManager({
|
||||
imex: "ImexPartsManagement",
|
||||
rome: "RomePartsManagement"
|
||||
})
|
||||
]
|
||||
InstanceRenderManager({
|
||||
imex: "ImexPartsManagement",
|
||||
rome: "RomePartsManagement"
|
||||
})
|
||||
]
|
||||
: [])
|
||||
];
|
||||
window.$crisp.push(["set", "session:segments", [segs]]);
|
||||
@@ -365,7 +365,7 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
|
||||
}
|
||||
|
||||
try {
|
||||
amplitude.setGroup('Shop', payload.shopname);
|
||||
//amplitude.setGroup('Shop', payload.shopname);
|
||||
window.$crisp.push(["set", "user:company", [payload.shopname]]);
|
||||
if (authRecord[0] && authRecord[0].user.validemail) {
|
||||
window.$crisp.push(["set", "user:email", [authRecord[0].user.email]]);
|
||||
@@ -375,17 +375,31 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
|
||||
const isParts = yield select((state) => state.application.isPartsEntry === true);
|
||||
const instanceSeg = InstanceRenderManager({ imex: "imex", rome: "rome" });
|
||||
|
||||
let featureSegments;
|
||||
if (payload.features?.allAccess === true) {
|
||||
featureSegments = ["allAccess"];
|
||||
} else {
|
||||
const featureKeys = Object.keys(payload.features).filter(
|
||||
(key) =>
|
||||
payload.features[key] === true ||
|
||||
(typeof payload.features[key] === "string" && !isNaN(Date.parse(payload.features[key])))
|
||||
);
|
||||
featureSegments = ["basic", ...featureKeys];
|
||||
}
|
||||
const featureSegments =
|
||||
payload.features?.allAccess === true
|
||||
? ["allAccess"]
|
||||
: [
|
||||
"basic",
|
||||
...Object.keys(payload.features).filter(
|
||||
(key) =>
|
||||
payload.features[key] === true ||
|
||||
(typeof payload.features[key] === "string" && !isNaN(Date.parse(payload.features[key])))
|
||||
)
|
||||
];
|
||||
|
||||
const additionalSegments = [
|
||||
payload.cdk_dealerid && "CDK",
|
||||
payload.pbs_serialnumber && "PBS",
|
||||
// payload.rr_dealerid && "Reynolds",
|
||||
payload.accountingconfig.qbo === true && "QBO",
|
||||
payload.accountingconfig.qbo === false &&
|
||||
!payload.cdk_dealerid &&
|
||||
!payload.pbs_serialnumber &&
|
||||
// !payload.rr_dealerid &&
|
||||
"QBD"
|
||||
].filter(Boolean);
|
||||
|
||||
featureSegments.push(...additionalSegments);
|
||||
|
||||
const regionSeg = payload.region_config ? `region:${payload.region_config}` : null;
|
||||
const segments = [instanceSeg, ...(regionSeg ? [regionSeg] : []), ...featureSegments];
|
||||
|
||||
@@ -281,6 +281,7 @@
|
||||
},
|
||||
"fields": {
|
||||
"ReceivableCustomField": "QBO Receivable Custom Field {{number}}",
|
||||
"accumulatePayableLines": "Accumulate Payable Lines",
|
||||
"address1": "Address 1",
|
||||
"address2": "Address 2",
|
||||
"appt_alt_transport": "Appointment Alternative Transportation Options",
|
||||
@@ -1269,6 +1270,7 @@
|
||||
"vehicle": "Vehicle"
|
||||
},
|
||||
"labels": {
|
||||
"selected": "Selected",
|
||||
"settings": "Settings",
|
||||
"actions": "Actions",
|
||||
"areyousure": "Are you sure?",
|
||||
@@ -1490,7 +1492,8 @@
|
||||
"assign_team": "Assign Team",
|
||||
"converttolabor": "Convert amount to Labor.",
|
||||
"dispatchparts": "Dispatch Parts ({{count}})",
|
||||
"new": "New Line"
|
||||
"new": "New Line",
|
||||
"updatelocation": "Update Location"
|
||||
},
|
||||
"errors": {
|
||||
"creating": "Error encountered while creating job line. {{message}}",
|
||||
@@ -1571,7 +1574,8 @@
|
||||
"ioucreated": "IOU",
|
||||
"new": "New Line",
|
||||
"nostatus": "No Status",
|
||||
"presets": "Jobline Presets"
|
||||
"presets": "Jobline Presets",
|
||||
"bulk_location_help": "This will set the same location on all selected lines."
|
||||
},
|
||||
"successes": {
|
||||
"created": "Job line created successfully.",
|
||||
@@ -1677,6 +1681,7 @@
|
||||
"actual_delivery": "Actual Delivery",
|
||||
"actual_in": "Actual In",
|
||||
"acv_amount": "ACV Amount",
|
||||
"admin_clerk": "Admin Clerk",
|
||||
"adjustment_bottom_line": "Adjustments",
|
||||
"adjustmenthours": "Adjustment Hours",
|
||||
"alt_transport": "Alt. Trans.",
|
||||
@@ -3207,6 +3212,7 @@
|
||||
"parts_not_recieved_vendor": "Parts Not Received by Vendor",
|
||||
"parts_received_not_scheduled": "Parts Received for Jobs Not Scheduled",
|
||||
"payments_by_date": "Payments by Date",
|
||||
"payments_by_date_excel": "Payments by Date - Excel",
|
||||
"payments_by_date_payment": "Payments by Date and Payment Type",
|
||||
"payments_by_date_type": "Payments by Date and Customer Type",
|
||||
"production_by_category": "Production by Category",
|
||||
|
||||
@@ -281,6 +281,7 @@
|
||||
},
|
||||
"fields": {
|
||||
"ReceivableCustomField": "",
|
||||
"accumulatePayableLines": "",
|
||||
"address1": "",
|
||||
"address2": "",
|
||||
"appt_alt_transport": "",
|
||||
@@ -1269,6 +1270,7 @@
|
||||
"vehicle": ""
|
||||
},
|
||||
"labels": {
|
||||
"selected": "",
|
||||
"actions": "Comportamiento",
|
||||
"settings": "",
|
||||
"areyousure": "",
|
||||
@@ -1490,7 +1492,8 @@
|
||||
"assign_team": "",
|
||||
"converttolabor": "",
|
||||
"dispatchparts": "",
|
||||
"new": ""
|
||||
"new": "",
|
||||
"updatelocation": ""
|
||||
},
|
||||
"errors": {
|
||||
"creating": "",
|
||||
@@ -1571,7 +1574,8 @@
|
||||
"ioucreated": "",
|
||||
"new": "Nueva línea",
|
||||
"nostatus": "",
|
||||
"presets": ""
|
||||
"presets": "",
|
||||
"bulk_location_help": ""
|
||||
},
|
||||
"successes": {
|
||||
"created": "",
|
||||
@@ -1678,6 +1682,7 @@
|
||||
"actual_in": "Real en",
|
||||
"acv_amount": "",
|
||||
"adjustment_bottom_line": "Ajustes",
|
||||
"admin_clerk": "",
|
||||
"adjustmenthours": "",
|
||||
"alt_transport": "",
|
||||
"area_of_damage_impact": {
|
||||
@@ -3207,6 +3212,7 @@
|
||||
"parts_not_recieved_vendor": "",
|
||||
"parts_received_not_scheduled": "",
|
||||
"payments_by_date": "",
|
||||
"payments_by_date_excel": "",
|
||||
"payments_by_date_payment": "",
|
||||
"payments_by_date_type": "",
|
||||
"production_by_category": "",
|
||||
|
||||
@@ -281,6 +281,7 @@
|
||||
},
|
||||
"fields": {
|
||||
"ReceivableCustomField": "",
|
||||
"accumulatePayableLines": "",
|
||||
"address1": "",
|
||||
"address2": "",
|
||||
"appt_alt_transport": "",
|
||||
@@ -1269,6 +1270,7 @@
|
||||
"vehicle": ""
|
||||
},
|
||||
"labels": {
|
||||
"selected": "",
|
||||
"settings": "",
|
||||
"actions": "actes",
|
||||
"areyousure": "",
|
||||
@@ -1490,7 +1492,8 @@
|
||||
"assign_team": "",
|
||||
"converttolabor": "",
|
||||
"dispatchparts": "",
|
||||
"new": ""
|
||||
"new": "",
|
||||
"updatelocation": ""
|
||||
},
|
||||
"errors": {
|
||||
"creating": "",
|
||||
@@ -1571,7 +1574,8 @@
|
||||
"ioucreated": "",
|
||||
"new": "Nouvelle ligne",
|
||||
"nostatus": "",
|
||||
"presets": ""
|
||||
"presets": "",
|
||||
"bulk_location_help": ""
|
||||
},
|
||||
"successes": {
|
||||
"created": "",
|
||||
@@ -1677,6 +1681,7 @@
|
||||
"actual_delivery": "Livraison réelle",
|
||||
"actual_in": "En réel",
|
||||
"acv_amount": "",
|
||||
"admin_clerk": "",
|
||||
"adjustment_bottom_line": "Ajustements",
|
||||
"adjustmenthours": "",
|
||||
"alt_transport": "",
|
||||
@@ -3207,6 +3212,7 @@
|
||||
"parts_not_recieved_vendor": "",
|
||||
"parts_received_not_scheduled": "",
|
||||
"payments_by_date": "",
|
||||
"payments_by_date_excel": "",
|
||||
"payments_by_date_payment": "",
|
||||
"payments_by_date_type": "",
|
||||
"production_by_category": "",
|
||||
|
||||
@@ -15,21 +15,29 @@ export const EmailSettings = {
|
||||
|
||||
export const TemplateList = (type, context) => {
|
||||
//const { bodyshop } = store.getState().user;
|
||||
|
||||
const casl = InstanceRenderManager({
|
||||
imex: {
|
||||
casl_authorization: {
|
||||
title: i18n.t("printcenter.jobs.casl_authorization"),
|
||||
description: "",
|
||||
subject: i18n.t("printcenter.jobs.casl_authorization"),
|
||||
key: "casl_authorization",
|
||||
disabled: false,
|
||||
group: "authorization",
|
||||
regions: {
|
||||
CA: true
|
||||
}
|
||||
}
|
||||
},
|
||||
rome: {}
|
||||
});
|
||||
|
||||
return {
|
||||
//If there's no type or the type is job, send it back.
|
||||
...(!type || type === "job"
|
||||
? {
|
||||
casl_authorization: {
|
||||
title: i18n.t("printcenter.jobs.casl_authorization"),
|
||||
description: "",
|
||||
subject: i18n.t("printcenter.jobs.casl_authorization"),
|
||||
key: "casl_authorization",
|
||||
disabled: false,
|
||||
group: "authorization",
|
||||
regions: {
|
||||
CA: true
|
||||
}
|
||||
},
|
||||
...casl,
|
||||
fippa_authorization: {
|
||||
title: i18n.t("printcenter.jobs.fippa_authorization"),
|
||||
description: "",
|
||||
@@ -1210,6 +1218,18 @@ export const TemplateList = (type, context) => {
|
||||
},
|
||||
group: "customers"
|
||||
},
|
||||
payments_by_date_excel: {
|
||||
title: i18n.t("reportcenter.templates.payments_by_date_excel"),
|
||||
subject: i18n.t("reportcenter.templates.payments_by_date_excel"),
|
||||
key: "payments_by_date_excel",
|
||||
reporttype: "excel",
|
||||
disabled: false,
|
||||
rangeFilter: {
|
||||
object: i18n.t("reportcenter.labels.objects.payments"),
|
||||
field: i18n.t("payments.fields.date")
|
||||
},
|
||||
group: "customers"
|
||||
},
|
||||
schedule: {
|
||||
title: i18n.t("reportcenter.templates.schedule"),
|
||||
subject: i18n.t("reportcenter.templates.schedule"),
|
||||
|
||||
@@ -31,7 +31,8 @@ if (!import.meta.env.DEV) {
|
||||
"Module specifier, 'fs' does not start",
|
||||
"Module specifier, 'zlib' does not start with",
|
||||
"Messaging: This browser doesn't support the API's required to use the Firebase SDK.",
|
||||
"Failed to update a ServiceWorker for scope"
|
||||
"Failed to update a ServiceWorker for scope",
|
||||
"Network Error"
|
||||
],
|
||||
integrations: [
|
||||
// See docs for support of different versions of variation of react router
|
||||
|
||||
@@ -24,11 +24,13 @@ const lightningCssTargets = browserslistToTargets(
|
||||
})
|
||||
);
|
||||
|
||||
const currentDatePST = new Date()
|
||||
.toLocaleDateString("en-US", { timeZone: "America/Los_Angeles", year: "numeric", month: "2-digit", day: "2-digit" })
|
||||
.split("/")
|
||||
.reverse()
|
||||
.join("-");
|
||||
const pstFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "America/Los_Angeles",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit"
|
||||
});
|
||||
const currentDatePST = pstFormatter.format(new Date());
|
||||
|
||||
const getFormattedTimestamp = () =>
|
||||
new Date().toLocaleTimeString("en-US", { hour12: true }).replace("AM", "a.m.").replace("PM", "p.m.");
|
||||
|
||||
@@ -207,6 +207,9 @@ services:
|
||||
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key
|
||||
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rome-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rps-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
"
|
||||
|
||||
networks:
|
||||
|
||||
@@ -120,6 +120,8 @@ services:
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-job-totals --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket parts-estimates --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rome-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rps-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
"
|
||||
# Node App: The Main IMEX API
|
||||
node-app:
|
||||
|
||||
@@ -15,6 +15,15 @@
|
||||
- name: x-imex-auth
|
||||
value_from_env: DATAPUMP_AUTH
|
||||
comment: Project Mexico
|
||||
- name: CARFAX RPS Data Pump
|
||||
webhook: '{{HASURA_API_URL}}/data/carfaxrps'
|
||||
schedule: 15 7 * * 0
|
||||
include_in_metadata: true
|
||||
payload: {}
|
||||
headers:
|
||||
- name: x-imex-auth
|
||||
value_from_env: DATAPUMP_AUTH
|
||||
comment: Project Mexico
|
||||
- name: Chatter Data Pump
|
||||
webhook: '{{HASURA_API_URL}}/data/chatter'
|
||||
schedule: 45 5 * * *
|
||||
|
||||
@@ -1156,7 +1156,11 @@
|
||||
enable_manual: false
|
||||
update:
|
||||
columns:
|
||||
- imexshopid
|
||||
- timezone
|
||||
- shopname
|
||||
- notification_followers
|
||||
- state
|
||||
- md_order_statuses
|
||||
retry_conf:
|
||||
interval_sec: 10
|
||||
@@ -3615,6 +3619,7 @@
|
||||
- adj_strdis
|
||||
- adj_towdis
|
||||
- adjustment_bottom_line
|
||||
- admin_clerk
|
||||
- agt_addr1
|
||||
- agt_addr2
|
||||
- agt_city
|
||||
@@ -3697,6 +3702,7 @@
|
||||
- deliverchecklist
|
||||
- depreciation_taxes
|
||||
- dms_allocation
|
||||
- dms_id
|
||||
- driveable
|
||||
- employee_body
|
||||
- employee_csr
|
||||
@@ -3890,6 +3896,7 @@
|
||||
- adj_strdis
|
||||
- adj_towdis
|
||||
- adjustment_bottom_line
|
||||
- admin_clerk
|
||||
- agt_addr1
|
||||
- agt_addr2
|
||||
- agt_city
|
||||
@@ -3973,6 +3980,7 @@
|
||||
- deliverchecklist
|
||||
- depreciation_taxes
|
||||
- dms_allocation
|
||||
- dms_id
|
||||
- driveable
|
||||
- employee_body
|
||||
- employee_csr
|
||||
@@ -4178,6 +4186,7 @@
|
||||
- adj_strdis
|
||||
- adj_towdis
|
||||
- adjustment_bottom_line
|
||||
- admin_clerk
|
||||
- agt_addr1
|
||||
- agt_addr2
|
||||
- agt_city
|
||||
@@ -4261,6 +4270,7 @@
|
||||
- deliverchecklist
|
||||
- depreciation_taxes
|
||||
- dms_allocation
|
||||
- dms_id
|
||||
- driveable
|
||||
- employee_body
|
||||
- employee_csr
|
||||
@@ -4705,6 +4715,34 @@
|
||||
- key
|
||||
- value
|
||||
filter: {}
|
||||
- table:
|
||||
name: media_analytics
|
||||
schema: public
|
||||
object_relationships:
|
||||
- name: bodyshop
|
||||
using:
|
||||
foreign_key_constraint_on: bodyshopid
|
||||
array_relationships:
|
||||
- name: media_analytics_details
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: media_analytics_id
|
||||
table:
|
||||
name: media_analytics_detail
|
||||
schema: public
|
||||
- table:
|
||||
name: media_analytics_detail
|
||||
schema: public
|
||||
object_relationships:
|
||||
- name: bodyshop
|
||||
using:
|
||||
foreign_key_constraint_on: bodyshopid
|
||||
- name: job
|
||||
using:
|
||||
foreign_key_constraint_on: jobid
|
||||
- name: media_analytic
|
||||
using:
|
||||
foreign_key_constraint_on: media_analytics_id
|
||||
- table:
|
||||
name: messages
|
||||
schema: public
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE "public"."media_analytics";
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE "public"."media_analytics" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now(), "bodyshopid" uuid NOT NULL, "total_jobs" integer NOT NULL DEFAULT 0, "total_documents" integer NOT NULL DEFAULT 0, "file_type_stats" jsonb NOT NULL DEFAULT jsonb_build_object(), PRIMARY KEY ("id") , FOREIGN KEY ("bodyshopid") REFERENCES "public"."bodyshops"("id") ON UPDATE restrict ON DELETE restrict);COMMENT ON TABLE "public"."media_analytics" IS E'LMS Media Analytics';
|
||||
CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
_new record;
|
||||
BEGIN
|
||||
_new := NEW;
|
||||
_new."updated_at" = NOW();
|
||||
RETURN _new;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
CREATE TRIGGER "set_public_media_analytics_updated_at"
|
||||
BEFORE UPDATE ON "public"."media_analytics"
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"();
|
||||
COMMENT ON TRIGGER "set_public_media_analytics_updated_at" ON "public"."media_analytics"
|
||||
IS 'trigger to set value of column "updated_at" to current timestamp on row update';
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE "public"."media_analytics_detail";
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE TABLE "public"."media_analytics_detail" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "created_at" timestamptz NOT NULL DEFAULT now(), "media_analytics_id" uuid NOT NULL, "jobid" uuid NOT NULL, "bodyshopid" uuid NOT NULL, "document_count" integer NOT NULL, "total_size_bytes" integer NOT NULL, "file_type_stats" jsonb NOT NULL DEFAULT jsonb_build_object(), PRIMARY KEY ("id") , FOREIGN KEY ("media_analytics_id") REFERENCES "public"."media_analytics"("id") ON UPDATE restrict ON DELETE restrict, FOREIGN KEY ("jobid") REFERENCES "public"."jobs"("id") ON UPDATE restrict ON DELETE restrict, FOREIGN KEY ("bodyshopid") REFERENCES "public"."bodyshops"("id") ON UPDATE restrict ON DELETE restrict);
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."jobs" add column "admin_clerk" text
|
||||
-- null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."jobs" add column "admin_clerk" text
|
||||
null;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."media_analytics" add column "total_size_bytes" integer
|
||||
-- null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."media_analytics" add column "total_size_bytes" integer
|
||||
null;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."media_analytics" add column "total_size_mb" numeric
|
||||
-- null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."media_analytics" add column "total_size_mb" numeric
|
||||
null;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."media_analytics_detail" add column "total_size_mb" numeric
|
||||
-- null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."media_analytics_detail" add column "total_size_mb" numeric
|
||||
null;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."media_analytics_detail" alter column "jobid" set not null;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."media_analytics_detail" alter column "jobid" drop not null;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "public"."media_analytics" ALTER COLUMN "total_size_bytes" TYPE integer;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "public"."media_analytics" ALTER COLUMN "total_size_bytes" TYPE numeric;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "public"."media_analytics_detail" ALTER COLUMN "total_size_bytes" TYPE integer;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "public"."media_analytics_detail" ALTER COLUMN "total_size_bytes" TYPE numeric;
|
||||
@@ -0,0 +1,5 @@
|
||||
alter table "public"."media_analytics_detail" drop constraint "media_analytics_detail_jobid_fkey",
|
||||
add constraint "media_analytics_detail_jobid_fkey"
|
||||
foreign key ("jobid")
|
||||
references "public"."jobs"
|
||||
("id") on update restrict on delete restrict;
|
||||
@@ -0,0 +1,5 @@
|
||||
alter table "public"."media_analytics_detail" drop constraint "media_analytics_detail_jobid_fkey",
|
||||
add constraint "media_analytics_detail_jobid_fkey"
|
||||
foreign key ("jobid")
|
||||
references "public"."jobs"
|
||||
("id") on update set null on delete set null;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- CREATE OR REPLACE FUNCTION set_fk_to_null_if_invalid_media_analytics()
|
||||
-- RETURNS TRIGGER AS $$
|
||||
-- BEGIN
|
||||
-- -- Check if the foreign key value is not NULL
|
||||
-- IF NEW.jobid IS NOT NULL THEN
|
||||
-- -- Check if the corresponding record exists in the parent table
|
||||
-- IF NOT EXISTS (SELECT 1 FROM jobs WHERE id = NEW.jobid) THEN
|
||||
-- -- If it doesn't exist, set the foreign key to NULL
|
||||
-- NEW.jobid = NULL;
|
||||
-- END IF;
|
||||
-- END IF;
|
||||
--
|
||||
-- -- Return the (potentially modified) record to be inserted/updated
|
||||
-- RETURN NEW;
|
||||
-- END;
|
||||
-- $$ LANGUAGE plpgsql;
|
||||
--
|
||||
-- CREATE TRIGGER media_analytics_fk_null
|
||||
-- BEFORE INSERT OR UPDATE ON media_analytics_detail
|
||||
-- FOR EACH ROW
|
||||
-- EXECUTE FUNCTION set_fk_to_null_if_invalid_media_analytics();
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE OR REPLACE FUNCTION set_fk_to_null_if_invalid_media_analytics()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Check if the foreign key value is not NULL
|
||||
IF NEW.jobid IS NOT NULL THEN
|
||||
-- Check if the corresponding record exists in the parent table
|
||||
IF NOT EXISTS (SELECT 1 FROM jobs WHERE id = NEW.jobid) THEN
|
||||
-- If it doesn't exist, set the foreign key to NULL
|
||||
NEW.jobid = NULL;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Return the (potentially modified) record to be inserted/updated
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER media_analytics_fk_null
|
||||
BEFORE INSERT OR UPDATE ON media_analytics_detail
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_fk_to_null_if_invalid_media_analytics();
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS "public"."media_analytics_detail_bodyshopid";
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX "media_analytics_detail_bodyshopid" on
|
||||
"public"."media_analytics_detail" using btree ("bodyshopid");
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS "public"."media_analytics_detail_jobid";
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX "media_analytics_detail_jobid" on
|
||||
"public"."media_analytics_detail" using btree ("jobid");
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS "public"."media_analytics_detail_media_analytics";
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX "media_analytics_detail_media_analytics" on
|
||||
"public"."media_analytics_detail" using btree ("media_analytics_id");
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."media_analytics" add column "unique_documents" numeric
|
||||
-- null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."media_analytics" add column "unique_documents" numeric
|
||||
null;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."media_analytics" add column "duplicate_documents" numeric
|
||||
-- null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."media_analytics" add column "duplicate_documents" numeric
|
||||
null;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."media_analytics_detail" add column "unique_documents" numeric
|
||||
-- null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."media_analytics_detail" add column "unique_documents" numeric
|
||||
null;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."media_analytics_detail" add column "duplicate_documents" numeric
|
||||
-- null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."media_analytics_detail" add column "duplicate_documents" numeric
|
||||
null;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."media_analytics_detail" rename column "unique_document_count" to "unique_documents";
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."media_analytics_detail" rename column "unique_documents" to "unique_document_count";
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."media_analytics_detail" rename column "duplicate_count" to "duplicate_documents";
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."media_analytics_detail" rename column "duplicate_documents" to "duplicate_count";
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."jobs" add column "dms_id" text
|
||||
-- null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."jobs" add column "dms_id" text
|
||||
null;
|
||||
2583
package-lock.json
generated
2583
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user