Compare commits

...

91 Commits

Author SHA1 Message Date
Allan Carr
ac6856b136 IO-3373 Dashboard Component Infinite Recursion
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-09-17 13:59:20 -07:00
Patrick Fic
ddd3b3d056 Merged in feature/IO-3325-amplitude-reverse-proxy (pull request #2566)
IO-3325 Add reverse proxy URL for amplitude.
2025-09-15 21:37:33 +00:00
Patrick Fic
2660466db1 IO-3325 Add reverse proxy URL for amplitude. 2025-09-15 14:13:48 -07:00
Dave Richer
fe67efe47c Merged in release/2025-09-26 (pull request #2562)
Release/2025 09 26  into master-AIO IO-3365, IO-3366, IO-3369
2025-09-11 20:43:43 +00:00
Dave
69a35772e5 release/2025-09-26 - Remove sockets from Parts Management 2025-09-11 16:41:21 -04:00
Dave Richer
38932f4bf9 Merged in feature/IO-3369-Fix-Parts-Status-List-Component (pull request #2559)
feature/IO-3369-Fix-Parts-Status-List-Component - Add Fixes
2025-09-11 19:58:28 +00:00
Dave
3fcb36a28e feature/IO-3369-Fix-Parts-Status-List-Component - Add Fixes 2025-09-11 15:56:47 -04:00
Allan Carr
fe78f5c7ff Merged in feature/IO-3365-Bills-Filters-and-Sorters (pull request #2556)
IO-3365 Bills Filters and Sorters

Approved-by: Dave Richer
2025-09-11 18:49:55 +00:00
Allan Carr
683846c3b0 Merged in feature/IO-3366-Shop-General-Field-Validators (pull request #2557)
IO-3366 Shop General Field Validators

Approved-by: Dave Richer
2025-09-11 18:49:20 +00:00
Dave Richer
2cc0b247b6 Merged in master-AIO (pull request #2555)
Master AIO
2025-09-11 18:18:26 +00:00
Dave Richer
31579354d4 Merged in release/2025-09-12 (pull request #2554)
DO NOT MERGE - Release/2025-09-12 into master-AIO -IO-3255, IO-3310, IO-3352, IO-3355
2025-09-11 15:31:22 +00:00
Dave Richer
0e7531dc54 Merged in feature/IO-3255-simplified-part-management (pull request #2552)
feature/IO-3255-simplified-parts-management -Extra checks
2025-09-11 15:24:38 +00:00
Dave
268b57c38a feature/IO-3255-simplified-parts-management -Extra checks 2025-09-11 11:22:59 -04:00
Allan Carr
2b8b8b8073 IO-3366 Shop General Field Validators
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-09-10 22:12:15 -07:00
Allan Carr
808eeb91e9 IO-3365 Bills Filters and Sorters
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-09-10 21:33:10 -07:00
Dave Richer
23dd8fc9de Merged in feature/IO-3255-simplified-part-management (pull request #2548)
feature/IO-3255-simplified-parts-management - Remove some stuff in vendors
2025-09-10 19:59:10 +00:00
Dave
f499859078 Merge remote-tracking branch 'origin/release/2025-09-12' into feature/IO-3255-simplified-part-management 2025-09-10 15:58:06 -04:00
Dave
84d9e3251a feature/IO-3255-simplified-parts-management - Remove some stuff in vendors 2025-09-10 15:57:54 -04:00
Dave Richer
bd7db4dd02 Merged in feature/IO-3255-simplified-part-management (pull request #2546)
feature/IO-3255-simplified-parts-management - Fix Manual line in Change Request
2025-09-10 16:44:59 +00:00
Dave
e9804b736b feature/IO-3255-simplified-parts-management - Fix Manual line in Change Request 2025-09-10 12:44:16 -04:00
Dave Richer
a14874f116 Merged in feature/Reynolds-and-Reynolds-DMS-API-Integration (pull request #2543)
Feature/Reynolds and Reynolds DMS API Integration
2025-09-09 16:00:01 +00:00
Dave
ac9fac458c feature/Reynolds-and-Reynolds-DMS-API-Integration - DB Modifications 2025-09-09 11:59:18 -04:00
Dave
8f9db15852 feature/Reynolds-and-Reynolds-DMS-API-Integration - DB Modifications 2025-09-09 11:59:08 -04:00
Dave Richer
61b3d3c18c Merged in feature/IO-3255-simplified-part-management (pull request #2540)
feature/IO-3255-simplified-parts-management - missing translations
2025-09-05 16:52:39 +00:00
Dave
e6f08d3b1c feature/IO-3255-simplified-parts-management - missing translations 2025-09-05 12:51:12 -04:00
Dave Richer
d5cf0f8371 Merged in feature/IO-3255-simplified-part-management (pull request #2538)
Feature/IO-3255 simplified part management
2025-09-05 16:46:39 +00:00
Dave
52e230fc54 feature/IO-3255-simplified-parts-management - bump deps 2025-09-05 12:45:46 -04:00
Dave
4011237c22 feature/IO-3255-simplified-parts-management - Checkpoint 2025-09-05 12:36:45 -04:00
Dave
c24bfbf655 feature/IO-3255-simplified-parts-management - Checkpoint 2025-09-05 12:31:45 -04:00
Allan Carr
08fe8c3c70 Merged in feature/IO-3355-Job-Cash-Discounting (pull request #2537)
Feature/IO-3355 Job Cash Discounting

Approved-by: Dave Richer
2025-09-05 16:30:47 +00:00
Dave
771a239773 feature/IO-3255-simplified-parts-management - Checkpoint 2025-09-05 12:16:25 -04:00
Dave
82195a0584 feature/IO-3255-simplified-parts-management - Checkpoint 2025-09-05 12:02:17 -04:00
Dave
838c24b3f1 feature/IO-3255-simplified-parts-management - Checkpoint 2025-09-05 11:03:20 -04:00
Dave
13cb68b0af feature/IO-3255-simplified-parts-management - Checkpoint 2025-09-05 10:18:03 -04:00
Dave
7c84b08707 feature/IO-3255-simplified-parts-management - Checkpoint 2025-09-05 10:12:37 -04:00
Dave
3165957e95 feature/IO-3255-simplified-parts-management - Checkpoint 2025-09-05 10:05:23 -04:00
Allan Carr
d9f59fcad4 IO-3355 Correct translation
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-09-04 15:27:06 -07:00
Allan Carr
edaeb5d77a IO-3355 Job Cash Discounting
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-09-04 15:16:48 -07:00
Allan Carr
5365d95d6f Merged in feature/IO-3310-Shop-Info-Data-Preservation (pull request #2533)
IO-3310 Shop Info Data Preservation

Approved-by: Dave Richer
2025-09-03 14:48:39 +00:00
Allan Carr
5cfefd5afd Merged in feature/IO-3352-Arrived-Color-on-Schedule (pull request #2532)
IO-3352 Arrived Color on Schedule

Approved-by: Dave Richer
2025-09-03 14:48:10 +00:00
Dave Richer
bbccdb0650 Merged in feature/IO-3255-simplified-part-management (pull request #2534)
feature/IO-3255-simplified-parts-management - Fix deprovision route
2025-09-03 14:47:51 +00:00
Dave
f3535c01af feature/IO-3255-simplified-parts-management - Fix deprovision route 2025-09-03 10:47:04 -04:00
Dave Richer
7f8c82b300 Merged in release/2025-08-29 (pull request #2528)
Release/2025 08 29 into master-AIO - IO-3182, IO-3255, IO-3325, IO-3330, IO-3340
2025-09-03 00:56:28 +00:00
Dave Richer
609ac2bd33 Merged in feature/IO-3255-simplified-part-management (pull request #2530)
Feature/IO-3255 simplified part management
2025-09-02 19:24:59 +00:00
Dave
0883274320 feature/IO-3255-simplified-parts-management - Expand deprovision route 2025-09-02 15:23:44 -04:00
Dave
fa33b88632 feature/IO-3255-simplified-parts-management - Expand deprovision route 2025-09-02 15:23:36 -04:00
Dave
bec32c1d70 feature/IO-3255-simplified-parts-management - Checkpoint 2025-09-02 15:01:56 -04:00
Allan Carr
eb18130e51 IO-3310 Shop Info Data Preservation
Correction to preserve hidden data

Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-09-01 20:29:39 -07:00
Allan Carr
0fbf63dec8 IO-3352 Arrived Color on Schedule
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-08-31 22:42:24 -07:00
Patrick Fic
f817902d5c Merged in feature/IO-3340-imgproxy-pdf-contenttype (pull request #2527)
IO-3340 Resolve unenforced content-type
2025-08-28 22:08:09 +00:00
Dave Richer
e5c0ace6cb Merged in feature/IO-3255-simplified-part-management (pull request #2525)
feature/IO-3255-simplified-parts-management - Checkpoint
2025-08-28 20:01:35 +00:00
Dave
814447373a feature/IO-3255-simplified-parts-management - Checkpoint 2025-08-28 16:00:39 -04:00
Dave Richer
d766a468c3 Merged in release/2025-08-29 (pull request #2523)
Release/2025 08 29
2025-08-28 19:36:10 +00:00
Dave
0ede2d0649 Merge remote-tracking branch 'origin/master-AIO' into release/2025-08-29 2025-08-28 15:33:26 -04:00
Dave Richer
54089c2ab3 Merged in feature/IO-3255-simplified-part-management (pull request #2521)
Feature/IO-3255 simplified part management
2025-08-28 19:26:25 +00:00
Dave
73e3d71cf1 feature/IO-3255-simplified-parts-management - Checkpoint 2025-08-28 14:50:26 -04:00
Dave
f071a5cc9e feature/IO-3255-simplified-parts-management - Checkpoint 2025-08-28 14:24:35 -04:00
Dave
67002b8443 Merge remote-tracking branch 'origin/feature/IO-3255-parts-management-review1' into feature/IO-3255-simplified-part-management 2025-08-28 14:06:07 -04:00
Dave
3f83c6afa7 Merge remote-tracking branch 'origin/release/2025-08-29' into feature/IO-3255-simplified-part-management 2025-08-28 13:27:16 -04:00
Dave
b7f57e91aa feature/IO-3255-simplified-parts-management - Checkpoint 2025-08-28 13:26:47 -04:00
Allan Carr
b8465c0cc7 Merged in feature/IO-3330-CARFAX-Query (pull request #2520)
IO-3330 CARFAX Bodyshop Query Adjustment

Approved-by: Dave Richer
2025-08-28 17:25:18 +00:00
Allan Carr
6bae0b8406 IO-3330 CARFAX Bodyshop Query Adjustment
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-08-27 23:41:16 -07:00
Patrick Fic
553c154e46 IO-3255 Review comments. 2025-08-27 11:50:00 -07:00
Dave Richer
35cbb921d2 Merged in feature/IO-3255-simplified-part-management (pull request #2516)
feature/IO-3255-simplified-parts-management - Change Parts Status logic for Parts Management
2025-08-27 18:19:40 +00:00
Dave
6b926401d0 feature/IO-3255-simplified-parts-management - Change Parts Status logic for Parts Management 2025-08-27 14:17:23 -04:00
Patrick Fic
5b400dce4f Merge branch 'feature/IO-3255-simplified-part-management' into feature/IO-3255-parts-management-review1 2025-08-27 09:39:55 -07:00
Dave Richer
914946c264 Merged in feature/IO-3255-simplified-part-management (pull request #2514)
feature/IO-3255-simplified-parts-management - Change Parts Status logic for Parts Management
2025-08-27 16:37:27 +00:00
Dave
2939b5795b feature/IO-3255-simplified-parts-management - Change Parts Status logic for Parts Management 2025-08-27 12:35:53 -04:00
Dave Richer
531f968ce6 Merged in feature/IO-3255-simplified-part-management (pull request #2512)
feature/IO-3255-simplified-parts-management - Change From Claim to JobID (chgrq)
2025-08-27 15:37:47 +00:00
Dave
24cc3fa6a4 feature/IO-3255-simplified-parts-management - Change From Claim to JobID (chgrq) 2025-08-27 11:36:39 -04:00
Dave Richer
a6f17e7db5 Merged in feature/IO-3255-simplified-part-management (pull request #2510)
feature/IO-3255-simplified-parts-management - Typo in chg request route
2025-08-26 14:51:18 +00:00
Dave
29c904feea feature/IO-3255-simplified-parts-management - Typo in chg request route 2025-08-26 10:50:44 -04:00
Dave Richer
0743048e75 Merged in feature/IO-3255-simplified-part-management (pull request #2509)
Feature/IO-3255 simplified part management
2025-08-25 16:58:43 +00:00
Dave
e2f1758378 feature/IO-3255-simplified-parts-management - Simplified Parts print center stacks on top of each other, not beside each other 2025-08-25 11:33:12 -04:00
Dave
b6dc7a4d92 feature/IO-3255-simplified-parts-management - Notes are appended if they are not duplicates 2025-08-25 11:30:18 -04:00
Dave Richer
130745d7e7 Merged in hotfix/2025-08-22 (pull request #2508)
DO NOT MERGE Hotfix/2025 08 22
2025-08-22 19:36:51 +00:00
Patrick Fic
a722ab9758 hotfix/2025-08-22 - Datahog 2025-08-22 15:35:55 -04:00
Patrick Fic
41c9c0be49 Add in amplititude 2025-08-22 15:22:45 -04:00
Patrick Fic
52f62126e1 Merged in feature/IO-3340-imgproxy-pdf-contenttype (pull request #2507)
IO-3340 Change application type on PDFs uploaded via imgproxy to allow inline display.
2025-08-22 19:02:44 +00:00
Allan Carr
eb04a8b6c5 Merged in feature/IO-3349-Chart-Enqueue-and-Label-Required (pull request #2505)
IO-3349 Chart Enqueue and Label Required

Approved-by: Dave Richer
2025-08-22 18:55:58 +00:00
Allan Carr
141b05f558 IO-3349 Chart Enqueue and Label Required
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-08-22 11:29:33 -07:00
Dave Richer
e9ea36fdad Merged in feature/IO-3255-simplified-part-management (pull request #2504)
feature/IO-3255-simplified-parts-management - Bug changes / Socket close cleanup
2025-08-22 18:26:38 +00:00
Patrick Fic
e24237e010 Merged in feature/IO-3325-amplitude (pull request #2502)
Feature/IO-3325 amplitude
2025-08-22 18:00:37 +00:00
Patrick Fic
f00f5f2e4a IO-3325 resolve partial commit error. 2025-08-22 10:57:38 -07:00
Patrick Fic
f38b550e75 IO-3325 Added posthog analytics for comparison. 2025-08-22 10:57:25 -07:00
Patrick Fic
fabe1508ac Merged in feature/IO-3325-amplitude (pull request #2500)
IO-3325 Add amplitude tracking.
2025-08-22 17:40:33 +00:00
Patrick Fic
5f3c880d0b IO-3325 Add amplitude tracking. 2025-08-22 10:40:11 -07:00
Patrick Fic
86f0c02c82 Merged in feature/IO-3348-es-vehicletype (pull request #2498)
ES-3348 Ass ES Vehicle type Route.
2025-08-22 15:53:22 +00:00
Patrick Fic
2004eb840f ES-3348 Ass ES Vehicle type Route. 2025-08-22 08:52:08 -07:00
Dave Richer
2f0190e190 Merged in feature/IO-3255-simplified-part-management (pull request #2496)
feature/IO-3255-simplified-parts-management - Missing breadcrumb / title translations
2025-08-22 15:31:40 +00:00
Dave Richer
8c9ef375be Merged in hotfix/background-colors-dark-mode (pull request #2485)
Hotfix/background colors dark mode
2025-08-18 18:39:42 +00:00
68 changed files with 5208 additions and 4553 deletions

View File

@@ -14,3 +14,6 @@ VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
VITE_APP_INSTANCE=IMEX
TEST_USERNAME="test@imex.dev"
TEST_PASSWORD="test123"
VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com

View File

@@ -16,3 +16,6 @@ VITE_APP_COUNTRY=USA
VITE_APP_INSTANCE=ROME
TEST_USERNAME="test@imex.dev"
TEST_PASSWORD="test123"
VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com

View File

@@ -13,3 +13,6 @@ VITE_APP_AXIOS_BASE_API_URL=https://api.imex.online/
VITE_APP_REPORTS_SERVER_URL=https://reports.imex.online
VITE_APP_SPLIT_API=et9pjkik6bn67he5evpmpr1agoo7gactphgk
VITE_APP_INSTANCE=IMEX
VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com

View File

@@ -13,3 +13,6 @@ VITE_APP_AXIOS_BASE_API_URL=https://api.romeonline.io/
VITE_APP_REPORTS_SERVER_URL=https://reports.romeonline.io
VITE_APP_SPLIT_API=et9pjkik6bn67he5evpmpr1agoo7gactphgk
VITE_APP_INSTANCE=ROME
VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com

View File

@@ -13,3 +13,6 @@ VITE_APP_REPORTS_SERVER_URL=https://reports.test.imex.online
VITE_APP_IS_TEST=true
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
VITE_APP_INSTANCE=IMEX
VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com

View File

@@ -13,3 +13,6 @@ VITE_APP_REPORTS_SERVER_URL=https://reports.test.romeonline.io
VITE_APP_IS_TEST=true
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
VITE_APP_INSTANCE=ROME
VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com

3146
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,46 +8,48 @@
"private": true,
"proxy": "http://localhost:4000",
"dependencies": {
"@amplitude/analytics-browser": "^2.23.5",
"@ant-design/pro-layout": "^7.22.6",
"@apollo/client": "^3.13.9",
"@emotion/is-prop-valid": "^1.3.1",
"@emotion/is-prop-valid": "^1.4.0",
"@fingerprintjs/fingerprintjs": "^4.6.1",
"@firebase/analytics": "^0.10.17",
"@firebase/app": "^0.14.1",
"@firebase/app": "^0.14.2",
"@firebase/auth": "^1.10.8",
"@firebase/firestore": "^4.8.0",
"@firebase/firestore": "^4.9.1",
"@firebase/messaging": "^0.12.22",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.8.2",
"@sentry/cli": "^2.52.0",
"@reduxjs/toolkit": "^2.9.0",
"@sentry/cli": "^2.53.0",
"@sentry/react": "^9.43.0",
"@sentry/vite-plugin": "^4.1.1",
"@sentry/vite-plugin": "^4.3.0",
"@splitsoftware/splitio-react": "^2.3.1",
"@tanem/react-nprogress": "^5.0.53",
"antd": "^5.27.1",
"antd": "^5.27.3",
"apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^4.4.0",
"autosize": "^6.0.1",
"axios": "^1.11.0",
"classnames": "^2.5.1",
"css-box-model": "^1.2.1",
"dayjs": "^1.11.13",
"dayjs": "^1.11.18",
"dayjs-business-days2": "^1.3.0",
"dinero.js": "^1.9.1",
"dotenv": "^17.2.1",
"dotenv": "^17.2.2",
"env-cmd": "^10.1.0",
"exifr": "^7.1.3",
"graphql": "^16.11.0",
"i18next": "^25.4.0",
"i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0",
"immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.13",
"libphonenumber-js": "^1.12.15",
"logrocket": "^9.0.2",
"markerjs2": "^2.32.6",
"memoize-one": "^6.0.0",
"normalize-url": "^8.0.2",
"object-hash": "^3.0.0",
"phone": "^3.1.67",
"posthog-js": "^1.261.7",
"prop-types": "^15.8.1",
"query-string": "^9.2.2",
"raf-schd": "^4.0.3",
@@ -55,11 +57,12 @@
"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",
"react-grid-layout": "1.3.4",
"react-i18next": "^15.7.1",
"react-i18next": "^15.7.3",
"react-icons": "^5.5.0",
"react-image-lightbox": "^5.1.4",
"react-markdown": "^10.1.0",
@@ -78,7 +81,7 @@
"redux-saga": "^1.3.0",
"redux-state-sync": "^3.1.4",
"reselect": "^5.1.1",
"sass": "^1.90.0",
"sass": "^1.92.0",
"socket.io-client": "^4.8.1",
"styled-components": "^6.1.19",
"subscriptions-transport-ws": "^0.11.0",
@@ -150,7 +153,6 @@
"eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0",
"jsdom": "^26.0.0",
"lightningcss": "^1.30.1",
"memfs": "^4.36.3",
"os-browserify": "^0.3.0",
"playwright": "^1.55.0",

View File

@@ -1,20 +1,20 @@
import { ApolloProvider } from "@apollo/client";
import * as Sentry from "@sentry/react";
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
import { ConfigProvider } from "antd";
import enLocale from "antd/es/locale/en_US";
import { useEffect, useMemo } from "react";
import { CookiesProvider } from "react-cookie";
import { useTranslation } from "react-i18next";
import { connect, useSelector } from "react-redux";
import { createStructuredSelector } from "reselect";
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
import { setDarkMode } from "../redux/application/application.actions";
import { selectDarkMode } from "../redux/application/application.selectors";
import { selectCurrentUser } from "../redux/user/user.selectors.js";
import client from "../utils/GraphQLClient";
import App from "./App";
import * as Sentry from "@sentry/react";
import getTheme from "./themeProvider";
import { CookiesProvider } from "react-cookie";
import { createStructuredSelector } from "reselect";
import { selectCurrentUser } from "../redux/user/user.selectors.js";
import { selectDarkMode } from "../redux/application/application.selectors";
import { setDarkMode } from "../redux/application/application.actions";
// Base Split configuration
const config = {

View File

@@ -233,9 +233,7 @@ export function App({
path="/parts/*"
element={
<ErrorBoundary>
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
<PrivateRoute isAuthorized={currentUser.authorized} />
</SocketProvider>
<PrivateRoute isAuthorized={currentUser.authorized} />
</ErrorBoundary>
}
>

View File

@@ -109,6 +109,13 @@ export function BillsListTableComponent({
key: "vendorname",
sorter: (a, b) => alphaSort(a.vendor.name, b.vendor.name),
sortOrder: state.sortedInfo.columnKey === "vendorname" && state.sortedInfo.order,
filters: bills
? [...new Set(bills.map((bill) => bill.vendor.name))].map((name) => ({
text: name,
value: name
}))
: [],
onFilter: (value, record) => record.vendor.name === value,
render: (text, record) => <span>{record.vendor.name}</span>
},
{

View File

@@ -80,7 +80,7 @@ export function DashboardGridComponent({ currentUser, bodyshop }) {
};
const handleAddComponent = (e) => {
logImEXEvent("dashboard_add_component", { name: e });
logImEXEvent("dashboard_add_component", { name: e.key });
setState({
...state,
items: [

View File

@@ -16,7 +16,7 @@ const mapStateToProps = createStructuredSelector({
export function GlobalFooter({ isPartsEntry }) {
const { t } = useTranslation();
if (isPartsEntry) {
return (
<Footer>
@@ -35,7 +35,6 @@ export function GlobalFooter({ isPartsEntry }) {
rome: t("titles.romeonline")
})} - ${import.meta.env.VITE_APP_GIT_SHA_DATE}`}
</div>
<WssStatusDisplayComponent />
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
Disclaimer & Notices
</Link>

View File

@@ -12,13 +12,15 @@ import InstanceRenderManager from "../../utils/instanceRenderMgr";
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
import JobLineConvertToLabor from "../job-line-convert-to-labor/job-line-convert-to-labor.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { selectIsPartsEntry } from "../../redux/application/application.selectors.js";
const mapStateToProps = createStructuredSelector({
technician: selectTechnician
technician: selectTechnician,
isPartsEntry: selectIsPartsEntry
});
const mapDispatchToProps = () => ({});
export function JobLinesPartPriceChange({ job, line, refetch, technician }) {
export function JobLinesPartPriceChange({ job, line, refetch, technician, isPartsEntry }) {
const [loading, setLoading] = useState(false);
const [updatePartPrice] = useMutation(UPDATE_LINE_PPC);
const notification = useNotification();
@@ -64,6 +66,7 @@ export function JobLinesPartPriceChange({ job, line, refetch, technician }) {
const popcontent =
!technician &&
!isPartsEntry &&
InstanceRenderManager({
imex: null,
rome: (

View File

@@ -481,48 +481,50 @@ export function JobLinesComponent({
{Enhanced_Payroll.treatment === "on" && (
<JobLineBulkAssignComponent selectedLines={selectedLines} setSelectedLines={setSelectedLines} job={job} />
)}
<Button
disabled={(job && !job.converted) || (selectedLines.length > 0 ? false : true) || jobRO || technician}
onClick={() => {
setBillEnterContext({
actions: { refetch: refetch },
context: {
disableInvNumber: true,
job: { id: job.id },
bill: {
vendorid: bodyshop.inhousevendorid,
invoice_number: "ih",
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
}
};
})
{!isPartsEntry && (
<Button
disabled={(job && !job.converted) || (selectedLines.length > 0 ? false : true) || jobRO || technician}
onClick={() => {
setBillEnterContext({
actions: { refetch: refetch },
context: {
disableInvNumber: true,
job: { id: job.id },
bill: {
vendorid: bodyshop.inhousevendorid,
invoice_number: "ih",
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
}
};
})
}
}
}
});
});
//Clear out the selected lines. IO-785
setSelectedLines([]);
}}
>
<HomeOutlined />
{t("parts.actions.orderinhouse")}
{selectedLines.length > 0 && ` (${selectedLines.length})`}
</Button>
//Clear out the selected lines. IO-785
setSelectedLines([]);
}}
>
<HomeOutlined />
{t("parts.actions.orderinhouse")}
{selectedLines.length > 0 && ` (${selectedLines.length})`}
</Button>
)}
<Button
id="job-lines-order-parts-button"
disabled={(job && !job.converted) || (selectedLines.length > 0 ? false : true) || jobRO || technician}
@@ -578,7 +580,8 @@ export function JobLinesComponent({
{t("joblines.actions.new")}
</Button>
)}
{InstanceRenderManager({ rome: <JobSendPartPriceChangeComponent job={job} disabled={technician} /> })}
{!isPartsEntry &&
InstanceRenderManager({ rome: <JobSendPartPriceChangeComponent job={job} disabled={technician} /> })}
<JobCreateIOU job={job} selectedJobLines={selectedLines} />
<Input.Search
placeholder={t("general.labels.search")}

View File

@@ -10,9 +10,12 @@ const mapStateToProps = createStructuredSelector({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export const DEFAULT_COL_LAYOUT = { xs: 24, sm: 24, md: 8, lg: 4, xl: 4, xxl: 4 };
export default connect(mapStateToProps, mapDispatchToProps)(JobPartsQueueCount);
export function JobPartsQueueCount({ bodyshop, parts, style }) {
export function JobPartsQueueCount({ bodyshop, parts, defaultColLayout = DEFAULT_COL_LAYOUT }) {
const partsStatus = useMemo(() => {
if (!parts) return null;
return parts.reduce(
@@ -35,35 +38,34 @@ export function JobPartsQueueCount({ bodyshop, parts, style }) {
}, [bodyshop, parts]);
if (!parts) return null;
return (
<Row style={style}>
<Col span={4}>
<Row>
<Col {...defaultColLayout}>
<Tooltip title="Total">
<Tag>{partsStatus.total}</Tag>
</Tooltip>
</Col>
<Col span={4}>
<Col {...defaultColLayout}>
<Tooltip title="No Status">
<Tag color="gold">{partsStatus["null"]}</Tag>
</Tooltip>
</Col>
<Col span={4}>
<Col {...defaultColLayout}>
<Tooltip title={bodyshop.md_order_statuses.default_ordered}>
<Tag color="blue">{partsStatus[bodyshop.md_order_statuses.default_ordered]}</Tag>
</Tooltip>
</Col>
<Col span={4}>
<Col {...defaultColLayout}>
<Tooltip title={bodyshop.md_order_statuses.default_received}>
<Tag color="green">{partsStatus[bodyshop.md_order_statuses.default_received]}</Tag>
</Tooltip>
</Col>
<Col span={4}>
<Col {...defaultColLayout}>
<Tooltip title={bodyshop.md_order_statuses.default_returned}>
<Tag color="orange">{partsStatus[bodyshop.md_order_statuses.default_returned]}</Tag>
</Tooltip>
</Col>
<Col span={4}>
<Col {...defaultColLayout}>
<Tooltip title={bodyshop.md_order_statuses.default_bo}>
<Tag color="red">{partsStatus[bodyshop.md_order_statuses.default_bo]}</Tag>
</Tooltip>

View File

@@ -20,35 +20,27 @@ export function JobTotalsCashDiscount({ bodyshop, amountDinero }) {
const notification = useNotification();
const fetchData = useCallback(async () => {
if (amountDinero && bodyshop) {
setLoading(true);
let response;
try {
response = await axios.post("/intellipay/checkfee", {
bodyshop: { id: bodyshop.id, imexshopid: bodyshop.imexshopid, state: bodyshop.state },
amount: Dinero(amountDinero).toFormat("0.00")
});
if (!amountDinero || !bodyshop) return;
if (response?.data?.error) {
notification.open({
type: "error",
message:
response.data?.error ||
"Error encountered when contacting IntelliPay service to determine cash discounted price."
});
} else {
setFee(response.data?.fee || 0);
}
} catch (error) {
notification.open({
type: "error",
message:
error.response?.data?.error ||
"Error encountered when contacting IntelliPay service to determine cash discounted price."
});
} finally {
setLoading(false);
setLoading(true);
const errorMessage = "Error encountered when contacting IntelliPay service to determine cash discounted price.";
try {
const { id, imexshopid, state } = bodyshop;
const { data } = await axios.post("/intellipay/checkfee", {
bodyshop: { id, imexshopid, state },
amount: Dinero(amountDinero).toUnit()
});
if (data?.error) {
notification.open({ type: "error", message: data.error || errorMessage });
} else {
setFee(data?.fee ?? 0);
}
} catch (error) {
notification.open({ type: "error", message: error.response?.data?.error || errorMessage });
} finally {
setLoading(false);
}
}, [amountDinero, bodyshop, notification]);

View File

@@ -8,19 +8,20 @@ import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { UPDATE_JOB_STATUS } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { selectIsPartsEntry, selectJobReadOnly } from "../../redux/application/application.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
jobRO: selectJobReadOnly
jobRO: selectJobReadOnly,
isPartsEntry: selectIsPartsEntry
});
const mapDispatchToProps = (dispatch) => ({
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
});
export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail }) {
export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail, isPartsEntry }) {
const { t } = useTranslation();
const [availableStatuses, setAvailableStatuses] = useState([]);
@@ -45,25 +46,43 @@ export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail }) {
});
};
// Updates available statuses based on job and bodyshop context
useEffect(() => {
//Figure out what scenario were in, populate accodingly
if (job && bodyshop) {
if (bodyshop.md_ro_statuses.pre_production_statuses.includes(job.status)) {
setAvailableStatuses(bodyshop.md_ro_statuses.pre_production_statuses);
} else if (bodyshop.md_ro_statuses.production_statuses.includes(job.status)) {
setAvailableStatuses(bodyshop.md_ro_statuses.production_statuses);
} else if (bodyshop.md_ro_statuses.post_production_statuses.includes(job.status)) {
setAvailableStatuses(
bodyshop.md_ro_statuses.post_production_statuses.filter(
(s) => s !== bodyshop.md_ro_statuses.default_invoiced && s !== bodyshop.md_ro_statuses.default_exported
)
);
} else {
console.log("Status didn't match any restrictions. Allowing all status changes.");
setAvailableStatuses(bodyshop.md_ro_statuses.statuses);
}
if (!job || !bodyshop) return;
const { md_ro_statuses } = bodyshop;
const {
parts_statuses,
pre_production_statuses,
production_statuses,
post_production_statuses,
statuses,
default_invoiced,
default_exported
} = md_ro_statuses;
if (isPartsEntry) {
// Set parts-specific statuses for parts entry scenario
setAvailableStatuses(parts_statuses);
return;
}
}, [job, setAvailableStatuses, bodyshop]);
// Handle non-parts entry scenarios based on job status
if (pre_production_statuses.includes(job.status)) {
setAvailableStatuses(pre_production_statuses);
} else if (production_statuses.includes(job.status)) {
setAvailableStatuses(production_statuses);
} else if (post_production_statuses.includes(job.status)) {
// Filter out invoiced and exported statuses for post-production
setAvailableStatuses(
post_production_statuses.filter((status) => status !== default_invoiced && status !== default_exported)
);
} else {
// Default to all statuses if no specific restrictions apply
console.log("Status didn't match any restrictions. Allowing all status changes.");
setAvailableStatuses(statuses);
}
}, [job, bodyshop, isPartsEntry, setAvailableStatuses]);
const statusMenu = {
items: [

View File

@@ -139,17 +139,18 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
<DataLabel label={t("jobs.fields.comment")} valueStyle={{ overflow: "hidden", textOverflow: "ellipsis" }}>
<ProductionListColumnComment record={job} />
</DataLabel>
<DataLabel label={t("jobs.fields.ins_co_nm_short")}>{job.ins_co_nm}</DataLabel>
{!isPartsEntry && <DataLabel label={t("jobs.fields.ins_co_nm_short")}>{job.ins_co_nm}</DataLabel>}
<DataLabel label={t("jobs.fields.clm_no")}>{job.clm_no}</DataLabel>
<DataLabel label={t("jobs.fields.ponumber")} hideIfNull>
{job.po_number}
</DataLabel>
<DataLabel label={t("jobs.fields.repairtotal")}>
<CurrencyFormatter>{job.clm_total}</CurrencyFormatter>
<span style={{ margin: "0rem .5rem" }}>/</span>
<CurrencyFormatter>{job.owner_owing}</CurrencyFormatter>
</DataLabel>
{!isPartsEntry && (
<DataLabel label={t("jobs.fields.repairtotal")}>
<CurrencyFormatter>{job.clm_total}</CurrencyFormatter>
<span style={{ margin: "0rem .5rem" }}>/</span>
<CurrencyFormatter>{job.owner_owing}</CurrencyFormatter>
</DataLabel>
)}
{!isPartsEntry && (
<>
<DataLabel label={t("jobs.fields.alt_transport")}>
@@ -176,7 +177,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
<Checkbox
checked={!!job.estimate_sent_approval}
onChange={(e) => handleCheckboxChange("estimate_sent_approval", e.target.checked)}
disabled={disabled}
disabled={disabled || isPartsEntry}
>
{job.estimate_sent_approval && (
<span style={{ color: "#888" }}>
@@ -191,7 +192,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
<Checkbox
checked={!!job.estimate_approved}
onChange={(e) => handleCheckboxChange("estimate_approved", e.target.checked)}
disabled={disabled}
disabled={disabled || isPartsEntry}
>
{job.estimate_approved && (
<span style={{ color: "#888" }}>
@@ -236,7 +237,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
<Card
style={{ height: "100%" }}
title={
disabled ? (
disabled || isPartsEntry ? (
<>{ownerTitle.length > 0 ? ownerTitle : t("owner.labels.noownerinfo")}</>
) : (
<Link to={`/manage/owners/${job.owner.id}`}>
@@ -247,14 +248,14 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
>
<div>
<DataLabel key="2" label={t("jobs.fields.ownr_ph1")}>
{disabled ? (
{disabled || isPartsEntry ? (
<PhoneNumberFormatter>{job.ownr_ph1}</PhoneNumberFormatter>
) : (
<ChatOpenButton phone={job.ownr_ph1} jobid={job.id} />
)}
</DataLabel>
<DataLabel key="22" label={t("jobs.fields.ownr_ph2")}>
{disabled ? (
{disabled || isPartsEntry ? (
<PhoneNumberFormatter>{job.ownr_ph2}</PhoneNumberFormatter>
) : (
<ChatOpenButton phone={job.ownr_ph2} jobid={job.id} />
@@ -266,7 +267,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
} ${job.ownr_st || ""} ${job.ownr_zip || ""}`}
</DataLabel>
<DataLabel key="4" label={t("owners.fields.ownr_ea")}>
{disabled ? (
{disabled || isPartsEntry ? (
<>{job.ownr_ea || ""}</>
) : job.ownr_ea ? (
<a href={`mailto:${job.ownr_ea}`}>{job.ownr_ea}</a>
@@ -316,7 +317,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
<DataLabel label={t("jobs.labels.relatedros")}>
<JobsRelatedRos jobid={job.id} job={job} disabled={disabled} />
</DataLabel>
{job.vehicle && job.vehicle.notes && (
{job.vehicle?.notes && (
<DataLabel
label={t("vehicles.fields.notes")}
valueStyle={{ whiteSpace: "pre-wrap" }}
@@ -326,7 +327,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
{job.vehicle.notes}
</DataLabel>
)}
{job.vehicle && job.vehicle.v_paint_codes && (
{job.vehicle?.v_paint_codes && (
<DataLabel label={t("vehicles.fields.v_paint_codes", { number: "" })}>
<span style={{ whiteSpace: "pre" }}>
{Object.keys(job.vehicle.v_paint_codes)

View File

@@ -1,8 +1,18 @@
import { Space, Tag } from "antd";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { selectIsPartsEntry } from "../../redux/application/application.selectors.js";
import { connect } from "react-redux";
import getPartsBasePath from "../../utils/getPartsBasePath.js";
export default function JobsRelatedRos({ job, disabled }) {
const mapStateToProps = createStructuredSelector({
isPartsEntry: selectIsPartsEntry
});
function JobsRelatedRos({ job, disabled, isPartsEntry }) {
if (!(job?.vehicle && job.vehicle.jobs)) return null;
const basePath = getPartsBasePath(isPartsEntry);
return (
<Space wrap>
{job.vehicle.jobs
@@ -12,7 +22,7 @@ export default function JobsRelatedRos({ job, disabled }) {
{disabled ? (
<>{`${j.ro_number || "N/A"}${j.clm_no ? ` | ${j.clm_no}` : ""}${j.status ? ` | ${j.status}` : ""}`}</>
) : (
<Link to={`/manage/jobs/${j?.id}`}>{`${j.ro_number || "N/A"}${
<Link to={`${basePath}/jobs/${j?.id}`}>{`${j.ro_number || "N/A"}${
j.clm_no ? ` | ${j.clm_no}` : ""
}${j.status ? ` | ${j.status}` : ""}`}</Link>
)}
@@ -21,3 +31,4 @@ export default function JobsRelatedRos({ job, disabled }) {
</Space>
);
}
export default connect(mapStateToProps)(JobsRelatedRos);

View File

@@ -227,15 +227,21 @@ export function PartsOrderListTableComponent({
sorter: (a, b) => a.order_date - b.order_date,
sortOrder: state.sortedInfo.columnKey === "order_date" && state.sortedInfo.order,
render: (text, record) => <DateFormatter>{record.order_date}</DateFormatter>
},
{
}
];
if (!isPartsEntry) {
columns.push({
title: t("parts_orders.fields.return"),
dataIndex: "return",
key: "return",
sorter: (a, b) => a.return - b.return,
sortOrder: state.sortedInfo.columnKey === "return" && state.sortedInfo.order,
render: (text, record) => <Checkbox checked={record.return} />
},
});
}
columns.push(
{
title: t("parts_orders.fields.deliver_by"),
dataIndex: "deliver_by",
@@ -256,7 +262,7 @@ export function PartsOrderListTableComponent({
render: (text, record) => recordActions(record, true),
id: "parts-order-list-table-actions"
}
];
);
const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });

View File

@@ -11,16 +11,27 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
import PartsOrderModalPriceChange from "./parts-order-modal-price-change.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import { selectIsPartsEntry } from "../../redux/application/application.selectors.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
bodyshop: selectBodyshop,
isPartsEntry: selectIsPartsEntry
});
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(PartsOrderModalComponent);
export function PartsOrderModalComponent({ bodyshop, vendorList, sendTypeState, isReturn, preferredMake, job, form }) {
export function PartsOrderModalComponent({
bodyshop,
vendorList,
sendTypeState,
isReturn,
preferredMake,
job,
form,
isPartsEntry
}) {
const [sendType, setSendType] = sendTypeState;
const {
@@ -83,7 +94,7 @@ export function PartsOrderModalComponent({ bodyshop, vendorList, sendTypeState,
</Space>
</Tag>
)}
{!isReturn && (
{!isReturn && !isPartsEntry && (
<Form.Item
name="removefrompartsqueue"
label={t("parts_orders.labels.removefrompartsqueue")}
@@ -92,7 +103,7 @@ export function PartsOrderModalComponent({ bodyshop, vendorList, sendTypeState,
<Checkbox />
</Form.Item>
)}
{OEConnection.treatment === "on" && !isReturn && (
{OEConnection.treatment === "on" && !isReturn && !isPartsEntry && (
<Form.Item name="is_quote" label={t("parts_orders.labels.is_quote")} valuePropName="checked">
<Checkbox />
</Form.Item>
@@ -249,7 +260,7 @@ export function PartsOrderModalComponent({ bodyshop, vendorList, sendTypeState,
<Radio disabled={is_quote} value={"p"}>
{t("parts_orders.labels.print")}
</Radio>
{OEConnection.treatment === "on" && !isReturn && (
{OEConnection.treatment === "on" && !isReturn && !isPartsEntry && (
<Radio value={"oec"}>{t("parts_orders.labels.oec")}</Radio>
)}
</Radio.Group>

View File

@@ -233,7 +233,7 @@ export function PartsQueueListComponent({ bodyshop }) {
title: t("jobs.fields.partsstatus"),
dataIndex: "partsstatus",
key: "partsstatus",
render: (text, record) => <JobPartsQueueCount style={{ minWidth: "10rem" }} parts={record.joblines_status} />
render: (text, record) => <JobPartsQueueCount parts={record.joblines_status} />
},
{
title: t("jobs.fields.comment"),

View File

@@ -37,7 +37,7 @@ export function PrintCenterJobsPartsComponent({ printCenterModal, bodyshop, tech
.filter(
(temp) =>
(!temp.regions ||
(temp.regions && temp.regions[bodyshop.region_config]) ||
temp.regions?.[bodyshop.region_config] ||
(temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)) &&
(!temp.dms || temp.dms === false)
)
@@ -46,7 +46,7 @@ export function PrintCenterJobsPartsComponent({ printCenterModal, bodyshop, tech
.filter(
(temp) =>
!temp.regions ||
(temp.regions && temp.regions[bodyshop.region_config]) ||
temp.regions?.[bodyshop.region_config] ||
(temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)
);
@@ -82,7 +82,7 @@ export function PrintCenterJobsPartsComponent({ printCenterModal, bodyshop, tech
variables: { id: jobId }
},
{
to: job && job.ownr_ea,
to: job?.ownr_ea,
subject: cards.find((c) => c.key === key)?.subject
},
"e",
@@ -129,7 +129,7 @@ export function PrintCenterJobsPartsComponent({ printCenterModal, bodyshop, tech
const columns = `repeat(${actions.length}, 1fr)`;
return (
<Col key={item.key} xs={24} sm={12}>
<Col key={item.key} xs={24} sm={24} md={24} lg={24} xl={24}>
<Card hoverable style={{ minHeight: 100 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ flex: "1 1 70%", minWidth: 0 }}>

View File

@@ -40,27 +40,26 @@ export function ScheduleCalendarWrapperComponent({
const currentView = search.view || defaultView || "week";
const handleEventPropStyles = (event) => {
const hasColor = Boolean(event?.color?.hex || event?.color);
const { color, block, arrived } = event ?? {};
const hasColor = Boolean(color?.hex || color);
const useBg = currentView !== "agenda";
// Prioritize explicit blocked-day background to ensure red in all themes
let bg;
if (useBg) {
if (event?.block) {
bg = "var(--event-block-bg)";
} else if (hasColor) {
bg = event?.color?.hex ?? event?.color;
} else {
bg = "var(--event-bg-fallback)";
}
bg = block
? "var(--event-block-bg)"
: arrived
? "var(--event-arrived-bg)"
: (color?.hex ?? color ?? "var(--event-bg-fallback)");
}
const usedFallback = !hasColor && !event?.block; // only mark as fallback when not blocked
const usedFallback = !hasColor && !block && !arrived; // only mark as fallback when not blocked or arrived
const classes = [
"imex-event",
event.arrived && "imex-event-arrived",
event.block && "imex-event-block",
arrived && "imex-event-arrived",
block && "imex-event-block",
usedFallback && "imex-event-fallback"
]
.filter(Boolean)

View File

@@ -23,13 +23,24 @@ export default function ShopInfoContainer() {
});
const notification = useNotification();
const combinedFeatureConfig = {
...FEATURE_CONFIGS.general,
...FEATURE_CONFIGS.responsibilitycenters
};
const combineFeatureConfigs = (...configs) =>
(configs || [])
.filter(Boolean)
.flatMap((cfg) => Object.entries(cfg))
.reduce((acc, [featureName, fieldPaths]) => {
if (!Array.isArray(fieldPaths)) return acc;
acc[featureName] = [...(acc[featureName] ?? []), ...fieldPaths];
return acc;
}, {});
const combinedFeatureConfig = combineFeatureConfigs(FEATURE_CONFIGS.general, FEATURE_CONFIGS.responsibilitycenters);
// Use form data preservation for all shop-info features
const { createSubmissionHandler } = useFormDataPreservation(form, data?.bodyshops[0], combinedFeatureConfig);
const { createSubmissionHandler, preserveHiddenFormData } = useFormDataPreservation(
form,
data?.bodyshops[0],
combinedFeatureConfig
);
const handleFinish = createSubmissionHandler((values) => {
setSaveLoading(true);
@@ -51,8 +62,11 @@ export default function ShopInfoContainer() {
});
useEffect(() => {
if (data) form.resetFields();
}, [form, data]);
if (!data) return;
form.resetFields();
// After reset, re-apply hidden field preservation so values aren't wiped
preserveHiddenFormData();
}, [data, form, preserveHiddenFormData]);
if (error) return <AlertComponent message={error.message} type="error" />;
if (loading) return <LoadingSpinner />;

View File

@@ -1183,7 +1183,17 @@ export function ShopInfoGeneral({ form, bodyshop }) {
{fields.map((field, index) => (
<Form.Item key={field.key}>
<LayoutFormRow noDivider>
<Form.Item label={t("general.labels.label")} key={`${index}label`} name={[field.name, "label"]}>
<Form.Item
label={t("general.labels.label")}
key={`${index}label`}
name={[field.name, "label"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
@@ -1294,7 +1304,17 @@ export function ShopInfoGeneral({ form, bodyshop }) {
{fields.map((field, index) => (
<Form.Item key={field.key}>
<LayoutFormRow noDivider>
<Form.Item label={t("general.labels.label")} key={`${index}label`} name={[field.name, "label"]}>
<Form.Item
label={t("general.labels.label")}
key={`${index}label`}
name={[field.name, "label"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
@@ -1483,15 +1503,31 @@ export function ShopInfoGeneral({ form, bodyshop }) {
{fields.map((field, index) => (
<Form.Item key={field.key}>
<LayoutFormRow noDivider>
<Form.Item label={t("general.labels.label")} key={`${index}label`} name={[field.name, "label"]}>
<Form.Item
label={t("general.labels.label")}
key={`${index}label`}
name={[field.name, "label"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.labels.md_to_emails_emails")}
key={`${index}emails`}
name={[field.name, "emails"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Select mode="tags" tokenSeparators={[",", ";"]} />
<FormItemEmail email={form.getFieldValue([field.name, "emails"])} />
</Form.Item>
<Space>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { HasFeatureAccess } from "./../feature-wrapper/feature-wrapper.component";
/**
@@ -8,73 +8,57 @@ import { HasFeatureAccess } from "./../feature-wrapper/feature-wrapper.component
* @param {Object} featureConfig - Configuration object defining which features and their associated fields to preserve
*/
export const useFormDataPreservation = (form, bodyshop, featureConfig) => {
const getNestedValue = (obj, path) => {
return path.reduce((current, key) => current?.[key], obj);
};
// Safe nested getters/setters using path arrays
const getNestedValue = (obj, path) => path?.reduce((acc, key) => acc?.[key], obj);
const setNestedValue = (obj, path, value) => {
const lastKey = path[path.length - 1];
const parentPath = path.slice(0, -1);
const parent = parentPath.reduce((current, key) => {
if (!current[key]) current[key] = {};
return current[key];
const parent = path.slice(0, -1).reduce((curr, key) => {
if (!curr[key] || typeof curr[key] !== "object") curr[key] = {};
return curr[key];
}, obj);
parent[lastKey] = value;
};
const preserveHiddenFormData = useCallback(() => {
const preservationData = {};
let hasDataToPreserve = false;
// Paths for features that are NOT accessible
const disabledPaths = useMemo(() => {
const result = [];
if (!featureConfig) return result;
Object.entries(featureConfig).forEach(([featureName, fieldPaths]) => {
const hasAccess = HasFeatureAccess({ featureName, bodyshop });
if (hasAccess || !Array.isArray(fieldPaths)) return;
fieldPaths.forEach((p) => Array.isArray(p) && p.length && result.push(p));
});
return result;
}, [featureConfig, bodyshop]);
if (!hasAccess) {
fieldPaths.forEach((fieldPath) => {
const currentValues = form.getFieldsValue();
let value = getNestedValue(currentValues, fieldPath);
const preserveHiddenFormData = useCallback(() => {
const currentValues = form.getFieldsValue();
const preservationData = {};
let hasAny = false;
if (value === undefined || value === null) {
value = getNestedValue(bodyshop, fieldPath);
}
if (value !== undefined && value !== null) {
setNestedValue(preservationData, fieldPath, value);
hasDataToPreserve = true;
}
});
disabledPaths.forEach((path) => {
let value = getNestedValue(currentValues, path);
if (value == null) value = getNestedValue(bodyshop, path);
if (value != null) {
setNestedValue(preservationData, path, value);
hasAny = true;
}
});
if (hasDataToPreserve) {
form.setFieldsValue(preservationData);
}
}, [form, featureConfig, bodyshop]);
if (hasAny) form.setFieldsValue(preservationData);
}, [form, bodyshop, disabledPaths]);
const getCompleteFormValues = () => {
const currentFormValues = form.getFieldsValue();
const completeValues = { ...currentFormValues };
const currentValues = form.getFieldsValue();
const complete = { ...currentValues };
Object.entries(featureConfig).forEach(([featureName, fieldPaths]) => {
const hasAccess = HasFeatureAccess({ featureName, bodyshop });
if (!hasAccess) {
fieldPaths.forEach((fieldPath) => {
let value = getNestedValue(currentFormValues, fieldPath);
if (value === undefined || value === null) {
value = getNestedValue(bodyshop, fieldPath);
}
if (value !== undefined && value !== null) {
setNestedValue(completeValues, fieldPath, value);
}
});
}
disabledPaths.forEach((path) => {
let value = getNestedValue(currentValues, path);
if (value == null) value = getNestedValue(bodyshop, path);
if (value != null) setNestedValue(complete, path, value);
});
return completeValues;
return complete;
};
const createSubmissionHandler = (originalHandler) => {
@@ -103,8 +87,8 @@ export const FEATURE_CONFIGS = {
["md_responsibility_centers", "profits"],
["md_responsibility_centers", "defaults"],
["md_responsibility_centers", "dms_defaults"],
["md_responsibility_centers", "taxes", "itemexemptcode"],
["md_responsibility_centers", "taxes", "invoiceexemptcode"],
["md_responsibility_centers", "taxes"],
["md_responsibility_centers", "cieca_pfl"],
["md_responsibility_centers", "ar"],
["md_responsibility_centers", "refund"],
["md_responsibility_centers", "sales_tax_codes"],

View File

@@ -9,7 +9,6 @@ import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { pageLimit } from "../../utils/config";
import { alphaSort, statusSort } from "../../utils/sorters";
import useLocalStorage from "../../utils/useLocalStorage";
@@ -96,13 +95,15 @@ export function SimplifiedPartsJobsListComponent({
key: "status",
ellipsis: true,
sorter: search?.search ? (a, b) => statusSort(a.status, b.status, bodyshop.md_ro_statuses.active_statuses) : true,
sorter: search?.search
? (a, b) => statusSort(a.status, b.status, bodyshop.md_ro_statuses.parts_active_statuses)
: true,
sortOrder: sortcolumn === "status" && sortorder,
render: (text, record) => {
return record.status || t("general.labels.na");
},
filteredValue: filter?.status || null,
filters: bodyshop.md_ro_statuses.statuses.map((s) => {
filters: bodyshop.md_ro_statuses.parts_statuses.map((s) => {
return { text: s, value: [s] };
}),
onFilter: (value, record) => value.includes(record.status)
@@ -142,31 +143,11 @@ export function SimplifiedPartsJobsListComponent({
sortOrder: sortcolumn === "clm_no" && sortorder,
render: (text, record) => `${record.clm_no || ""}${record.po_number ? ` (PO: ${record.po_number})` : ""}`
},
{
title: t("jobs.fields.ins_co_nm"),
dataIndex: "ins_co_nm",
key: "ins_co_nm",
ellipsis: true
},
{
title: t("jobs.fields.clm_total"),
dataIndex: "clm_total",
key: "clm_total",
sorter: search?.search ? (a, b) => a.clm_total - b.clm_total : true,
sortOrder: sortcolumn === "clm_total" && sortorder,
render: (text, record) => {
return record.clm_total ? (
<CurrencyFormatter>{record.clm_total}</CurrencyFormatter>
) : (
t("general.labels.unknown")
);
}
},
{
title: t("jobs.fields.partsstatus"),
dataIndex: "partsstatus",
key: "partsstatus",
render: (text, record) => <JobPartsQueueCount style={{ minWidth: "10rem" }} parts={record.joblines_status} />
render: (text, record) => <JobPartsQueueCount parts={record.joblines_status} />
},
{
title: t("jobs.fields.comment"),

View File

@@ -14,16 +14,18 @@ import VendorsPhonebookAdd from "../vendors-phonebook-add/vendors-phonebook-add.
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { selectIsPartsEntry } from "../../redux/application/application.selectors.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
bodyshop: selectBodyshop,
isPartsEntry: selectIsPartsEntry
});
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(VendorsFormComponent);
export function VendorsFormComponent({ bodyshop, form, formLoading, handleDelete, selectedvendor }) {
export function VendorsFormComponent({ bodyshop, form, formLoading, handleDelete, selectedvendor, isPartsEntry }) {
const { t } = useTranslation();
const client = useApolloClient();
@@ -57,8 +59,7 @@ export function VendorsFormComponent({ bodyshop, form, formLoading, handleDelete
>
{t("general.actions.delete")}
</Button>
<VendorsPhonebookAdd form={form} disabled={form.isFieldsTouched()} />
{!isPartsEntry && <VendorsPhonebookAdd form={form} disabled={form.isFieldsTouched()} />}
</Space>
}
/>
@@ -148,12 +149,18 @@ export function VendorsFormComponent({ bodyshop, form, formLoading, handleDelete
</Form.Item>
</LayoutFormRow>
<LayoutFormRow grow>
<Form.Item label={t("vendors.fields.discount")} name="discount">
<InputNumber min={0} max={1} precision={2} step={0.01} />
</Form.Item>
<Form.Item label={t("vendors.fields.due_date")} name="due_date">
<InputNumber min={0} />
</Form.Item>
{!isPartsEntry && (
<>
<Form.Item label={t("vendors.fields.discount")} name="discount">
<InputNumber min={0} max={1} precision={2} step={0.01} />
</Form.Item>
<Form.Item label={t("vendors.fields.due_date")} name="due_date">
<InputNumber min={0} />
</Form.Item>
</>
)}
{
// <Form.Item
// label={t("vendors.fields.cost_center")}
@@ -173,7 +180,7 @@ export function VendorsFormComponent({ bodyshop, form, formLoading, handleDelete
<Form.Item
name="tags"
label={t("vendor.fields.tags")}
label={t("vendors.fields.tags")}
rules={[
{
//message: t("general.validation.required"),

View File

@@ -4,6 +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'
const config = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
initializeApp(config);
@@ -71,25 +73,33 @@ onMessage(messaging, (payload) => {
});
export const logImEXEvent = (eventName, additionalParams, stateProp = null) => {
const state = stateProp || store.getState();
const eventParams = {
shop: (state.user && state.user.bodyshop && state.user.bodyshop.shopname) || null,
user: (state.user && state.user.currentUser && state.user.currentUser.email) || null,
...additionalParams
};
// axios.post("/ioevent", {
// useremail: (state.user && state.user.currentUser && state.user.currentUser.email) || null,
// bodyshopid: (state.user && state.user.bodyshop && state.user.bodyshop.id) || null,
// operationName: eventName,
// variables: additionalParams,
// 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
// );
logEvent(analytics, eventName, eventParams);
try {
const state = stateProp || store.getState();
const eventParams = {
shop: (state.user && state.user.bodyshop && state.user.bodyshop.shopname) || null,
user: (state.user && state.user.currentUser && state.user.currentUser.email) || null,
...additionalParams
};
// axios.post("/ioevent", {
// useremail: (state.user && state.user.currentUser && state.user.currentUser.email) || null,
// bodyshopid: (state.user && state.user.bodyshop && state.user.bodyshop.id) || null,
// operationName: eventName,
// variables: additionalParams,
// 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
// );
logEvent(analytics, eventName, eventParams);
amplitude.track(eventName, eventParams);
posthog.capture(eventName, eventParams);
} finally {
//If it fails, just keep going.
}
};

View File

@@ -14,6 +14,9 @@ import { persistor, store } from "./redux/store";
import reportWebVitals from "./reportWebVitals";
import "./translations/i18n";
import "./utils/CleanAxios";
import * as amplitude from "@amplitude/analytics-browser";
import { PostHogProvider } from "posthog-js/react";
import posthog from "posthog-js";
window.global ||= window;
@@ -23,10 +26,33 @@ registerSW({ immediate: true });
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
amplitude.init("6228a598e57cd66875cfd41604f1f891", {
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,
capture_exceptions: true,
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST
});
const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV6(createBrowserRouter);
const router = sentryCreateBrowserRouter(createRoutesFromElements(<Route path="*" element={<AppContainer />} />));
if (import.meta.env.DEV) {
let styles =
"font-weight: bold; font-size: 50px;color: red; 6px 6px 0 rgb(226,91,14) , 9px 9px 0 rgb(245,221,8) , 12px 12px 0 rgb(5,148,68) ";
@@ -37,7 +63,9 @@ function App() {
return (
<PersistGate loading={<LoadingSpinner message="Restoring your settings..." />} persistor={persistor}>
<Provider store={store}>
<RouterProvider router={router} />
<PostHogProvider client={posthog}>
<RouterProvider router={router} />
</PostHogProvider>
</Provider>
</PersistGate>
);

View File

@@ -1,5 +1,6 @@
import { EditFilled, SyncOutlined } from "@ant-design/icons";
import { Button, Card, Checkbox, Input, Space, Table, Typography } from "antd";
import { useQuery } from "@apollo/client";
import axios from "axios";
import queryString from "query-string";
import { useEffect, useState } from "react";
@@ -16,6 +17,7 @@ import { TemplateList } from "../../utils/TemplateConstants";
import { pageLimit } from "../../utils/config";
import { alphaSort, dateSort } from "../../utils/sorters";
import useLocalStorage from "../../utils/useLocalStorage";
import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries";
const mapDispatchToProps = (dispatch) => ({
setBillEnterContext: (context) => dispatch(setModalContext({ context: context, modal: "billEnter" }))
@@ -33,25 +35,22 @@ export function BillsListPage({ loading, data, refetch, total, setBillEnterConte
});
const Templates = TemplateList("bill");
const { t } = useTranslation();
const { data: vendorsData } = useQuery(QUERY_ALL_VENDORS);
const columns = [
{
title: t("bills.fields.vendorname"),
dataIndex: "vendorname",
key: "vendorname",
// sortObject: (direction) => {
// return {
// vendor: {
// name: direction
// ? direction === "descend"
// ? "desc"
// : "asc"
// : "desc",
// },
// };
// },
// sorter: (a, b) => alphaSort(a.vendor.name, b.vendor.name),
// sortOrder:
// state.sortedInfo.columnKey === "vendorname" && state.sortedInfo.order,
sorter: (a, b) => alphaSort(a.vendor.name, b.vendor.name),
sortObject: (order) => ({
vendor: { name: order === "descend" ? "desc" : "asc" }
}),
filters: (vendorsData?.vendors || []).map((v) => ({ text: v.name, value: v.id })),
filteredValue: state.filteredInfo.vendorname || null,
onFilter: (value, record) => record.vendorid === value,
sortOrder: state.sortedInfo.columnKey === "vendorname" && state.sortedInfo.order,
render: (text, record) => <span>{record.vendor.name}</span>
},
{
@@ -65,20 +64,11 @@ export function BillsListPage({ loading, data, refetch, total, setBillEnterConte
title: t("jobs.fields.ro_number"),
dataIndex: "ro_number",
key: "ro_number",
// sortObject: (direction) => {
// return {
// job: {
// ro_number: direction
// ? direction === "descend"
// ? "desc"
// : "asc"
// : "desc",
// },
// };
// },
// sorter: (a, b) => alphaSort(a.job.ro_number, b.job.ro_number),
// sortOrder:
// state.sortedInfo.columnKey === "ro_number" && state.sortedInfo.order,
sorter: (a, b) => alphaSort(a.job.ro_number, b.job.ro_number),
sortObject: (order) => ({
job: { ro_number: order === "descend" ? "desc" : "asc" }
}),
sortOrder: state.sortedInfo.columnKey === "ro_number" && state.sortedInfo.order,
render: (text, record) => record.job && <Link to={`/manage/jobs/${record.job.id}`}>{record.job.ro_number}</Link>
},
{
@@ -175,7 +165,8 @@ export function BillsListPage({ loading, data, refetch, total, setBillEnterConte
];
const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
// Persist filters (including vendorname) and sorting
setState({ ...state, filteredInfo: { ...state.filteredInfo, ...filters }, sortedInfo: sorter });
search.page = pagination.current;
if (sorter && sorter.column && sorter.column.sortObject) {
search.searchObj = JSON.stringify(sorter.column.sortObject(sorter.order));

View File

@@ -11,7 +11,6 @@ import { createStructuredSelector } from "reselect";
import FormFieldsChanged from "../../components/form-fields-changed-alert/form-fields-changed-alert.component.jsx";
import JobsLinesContainer from "../../components/job-detail-lines/job-lines.container.jsx";
import JobLineUpsertModalContainer from "../../components/job-lines-upsert-modal/job-lines-upsert-modal.container.jsx";
import JobProfileDataWarning from "../../components/job-profile-data-warning/job-profile-data-warning.component.jsx";
import JobsChangeStatus from "../../components/jobs-change-status/jobs-change-status.component.jsx";
import JobsDetailHeaderActions from "../../components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx";
import JobsDetailHeader from "../../components/jobs-detail-header/jobs-detail-header.component.jsx";
@@ -133,9 +132,8 @@ export function SimplifiedPartsJobDetailComponent({ setPrintCenterContext, jobRO
<JobLineUpsertModalContainer />
<PageHeader title={<Space>{job.ro_number || t("general.labels.na")}</Space>} extra={menuExtra} />
<JobsDetailHeader job={job} disabled={true} />
<JobsDetailHeader job={job} />
<Divider type="horizontal" />
<JobProfileDataWarning job={job} />
<FormFieldsChanged form={form} />
<Tabs
defaultActiveKey={search.tab}

View File

@@ -49,6 +49,8 @@ import {
validatePasswordResetSuccess
} from "./user.actions";
import UserActionTypes from "./user.types";
import * as amplitude from '@amplitude/analytics-browser';
import posthog from 'posthog-js';
const fpPromise = FingerprintJS.load();
@@ -83,8 +85,6 @@ export function* onCheckUserSession() {
export function* isUserAuthenticated() {
try {
logImEXEvent("redux_auth_check");
const user = yield getCurrentUser();
if (!user) {
yield put(unauthorizedUser());
@@ -92,6 +92,8 @@ export function* isUserAuthenticated() {
}
LogRocket.identify(user.email);
amplitude.setUserId(user.email);
posthog.identify(user.email);
const eulaQuery = yield client.query({
query: QUERY_EULA,
@@ -137,6 +139,7 @@ export function* signOutStart() {
imexshopid: state.user.bodyshop.imexshopid,
type: "messaging"
});
amplitude.reset();
} catch {
console.log("No FCM token. Skipping unsubscribe.");
}
@@ -266,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]]);
@@ -295,7 +298,7 @@ export function* signInSuccessSaga({ payload }) {
setUserId(analytics, payload.email);
setUserProperties(analytics, payload);
yield logImEXEvent("redux_sign_in_success");
yield;
}
export function* onSendPasswordResetStart() {
@@ -362,6 +365,7 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
}
try {
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]]);

View File

@@ -1249,7 +1249,8 @@
"sizelimit": "The selected items exceed the size limit.",
"sub_status": {
"expired": "The subscription for this shop has expired. Please contact Sales to reactivate.",
"trial-expired": "The trial for this shop has expired. Please contact Sales to reactivate."
"trial-expired": "The trial for this shop has expired. Please contact Sales to reactivate.",
"undefined": "The subscription for this shop is removed. Please contact Sales to reactivate."
},
"submit-for-testing": "Error submitting Job for testing."
},

View File

@@ -1249,7 +1249,8 @@
"sizelimit": "",
"sub_status": {
"expired": "",
"trial-expired": ""
"trial-expired": "",
"undefined": ""
},
"submit-for-testing": ""
},

View File

@@ -1249,7 +1249,8 @@
"sizelimit": "",
"sub_status": {
"expired": "",
"trial-expired": ""
"trial-expired": "",
"undefined": ""
},
"submit-for-testing": ""
},

View File

@@ -181,7 +181,10 @@ const cache = new InMemoryCache({
const client = new ApolloClient({
link: ApolloLink.from(middlewares),
cache,
connectToDevTools: import.meta.env.DEV,
devtools: {
name: "Imex Client",
enabled: import.meta.env.DEV
},
defaultOptions: {
watchQuery: {
fetchPolicy: "network-only",

View File

@@ -21,7 +21,7 @@ services:
- redis-node-1-data:/data
- redis-lock:/redis-lock
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: [ "CMD", "redis-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 10
@@ -39,7 +39,7 @@ services:
- redis-node-2-data:/data
- redis-lock:/redis-lock
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: [ "CMD", "redis-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 10
@@ -57,7 +57,7 @@ services:
- redis-node-3-data:/data
- redis-lock:/redis-lock
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: [ "CMD", "redis-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 10
@@ -85,7 +85,7 @@ services:
ports:
- "4566:4566"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"]
test: [ "CMD", "curl", "-f", "http://localhost:4566/_localstack/health" ]
interval: 10s
timeout: 5s
retries: 5
@@ -118,6 +118,7 @@ services:
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-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
"
# Node App: The Main IMEX API
node-app:

View File

@@ -959,6 +959,7 @@
- enforce_referral
- entegral_configuration
- entegral_id
- external_shop_id
- features
- federal_tax_id
- id
@@ -1012,6 +1013,8 @@
- prodtargethrs
- production_config
- region_config
- rr_configuration
- rr_dealerid
- schedule_end_time
- schedule_start_time
- scoreboard_target
@@ -1035,7 +1038,6 @@
- use_fippa
- use_paint_scale_data
- uselocalmediaserver
- external_shop_id
- website
- workingdays
- zip_post
@@ -1068,6 +1070,7 @@
- enforce_conversion_category
- enforce_conversion_csr
- enforce_referral
- external_shop_id
- federal_tax_id
- id
- inhousevendorid
@@ -1113,6 +1116,7 @@
- phone
- prodtargethrs
- production_config
- rr_configuration
- schedule_end_time
- schedule_start_time
- scoreboard_target
@@ -1131,7 +1135,6 @@
- use_fippa
- use_paint_scale_data
- uselocalmediaserver
- external_shop_id
- website
- workingdays
- zip_post

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."bodyshops" add column "rr_configuration" jsonb
-- null default jsonb_build_object();

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" add column "rr_configuration" jsonb
null default jsonb_build_object();

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."bodyshops" add column "rr_dealierid" text
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" add column "rr_dealierid" text
null;

View File

@@ -0,0 +1 @@
alter table "public"."bodyshops" rename column "rr_dealerid" to "rr_dealierid";

View File

@@ -0,0 +1 @@
alter table "public"."bodyshops" rename column "rr_dealierid" to "rr_dealerid";

3235
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,14 +18,14 @@
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
},
"dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.873.0",
"@aws-sdk/client-elasticache": "^3.872.0",
"@aws-sdk/client-s3": "^3.872.0",
"@aws-sdk/client-secrets-manager": "^3.872.0",
"@aws-sdk/client-ses": "^3.872.0",
"@aws-sdk/credential-provider-node": "^3.873.0",
"@aws-sdk/lib-storage": "^3.872.0",
"@aws-sdk/s3-request-presigner": "^3.872.0",
"@aws-sdk/client-cloudwatch-logs": "^3.882.0",
"@aws-sdk/client-elasticache": "^3.882.0",
"@aws-sdk/client-s3": "^3.882.0",
"@aws-sdk/client-secrets-manager": "^3.882.0",
"@aws-sdk/client-ses": "^3.882.0",
"@aws-sdk/credential-provider-node": "^3.882.0",
"@aws-sdk/lib-storage": "^3.882.0",
"@aws-sdk/s3-request-presigner": "^3.882.0",
"@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0",
@@ -33,18 +33,18 @@
"aws4": "^1.13.2",
"axios": "^1.11.0",
"better-queue": "^3.8.12",
"bullmq": "^5.58.0",
"bullmq": "^5.58.5",
"chart.js": "^4.5.0",
"cloudinary": "^2.7.0",
"compression": "^1.8.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"crisp-status-reporter": "^1.2.2",
"dd-trace": "^5.63.3",
"dd-trace": "^5.65.0",
"dinero.js": "^1.9.1",
"dotenv": "^17.2.1",
"dotenv": "^17.2.2",
"express": "^4.21.1",
"firebase-admin": "^13.4.0",
"firebase-admin": "^13.5.0",
"graphql": "^16.11.0",
"graphql-request": "^6.1.0",
"intuit-oauth": "^4.2.0",
@@ -62,12 +62,12 @@
"query-string": "7.1.3",
"recursive-diff": "^1.0.9",
"rimraf": "^6.0.1",
"skia-canvas": "^3.0.3",
"skia-canvas": "^3.0.6",
"soap": "^1.3.0",
"socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.5",
"ssh2-sftp-client": "^11.0.0",
"twilio": "^5.8.0",
"twilio": "^5.9.0",
"uuid": "^11.1.0",
"winston": "^3.17.0",
"winston-cloudwatch": "^6.3.0",
@@ -76,8 +76,8 @@
"yazl": "^3.3.1"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"eslint": "^9.33.0",
"@eslint/js": "^9.35.0",
"eslint": "^9.35.0",
"eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0",
"mock-require": "^3.0.3",

View File

@@ -6,4 +6,5 @@ exports.kaizen = require("./kaizen").default;
exports.usageReport = require("./usageReport").default;
exports.podium = require("./podium").default;
exports.emsUpload = require("./emsUpload").default;
exports.carfax = require("./carfax").default;
exports.carfax = require("./carfax").default;
exports.vehicletype = require("./vehicletype/vehicletype").default;

View File

@@ -0,0 +1,126 @@
[
"PROMASTER 1500",
"PROMASTER 2500",
"PROMASTER CITY",
"NV 1500",
"NV 200",
"NV 2500",
"NV 3500",
"NV1500",
"NV200",
"NV2500",
"NV3500",
"SPRINTER",
"E150 ECONOLINE CARGO VAN",
"E150 ECONOLINE XL",
"E250 ECONOLINE CARGO",
"E250 ECONOLINE CARGO (AMALGAM)",
"E250 ECONOLINE CARGO (INSPECT)",
"E250 ECONOLINE CARGO VAN EXT",
"E250 ECONOLINE SUPER CARGO VAN",
"E350 CUTAWAY VAN",
"E350 ECONO SD CARGO VAN EXT",
"E350 ECONOLINE CARGO VAN",
"E350 ECONOLINE CUTAWAY",
"E350 ECONOLINE SD CARGO VAN",
"E350 ECONOLINE SD XL",
"E350 ECONOLINE SD XL EXT",
"E350 ECONOLINE SD XLT",
"E350 ECONOLINE SD XLT EXT",
"E350 SD CUTAWAY",
"E450",
"E450 ECONOLINE",
"E450 ECONOLINE SD",
"E450 ECONOLINE SD CUTAWAY",
"TRANSIT 150 WB 130 CARGO VAN",
"TRANSIT 150 WB 130 XLT",
"TRANSIT 150 WB 148 CARGO VAN",
"TRANSIT 250 WB 130 CARGO VAN",
"TRANSIT 250 WB 148 CARGO VAN",
"TRANSIT 250 WB 148 EL CARGO",
"TRANSIT 350 WB 148 CARGO VAN",
"TRANSIT 350 WB 148 EL CARGO",
"TRANSIT CONNECT XL CARGO VAN",
"TRANSIT CONNECT XLT CARGO VAN",
"250 TRANSIT",
"CITY EXPRESS LS CARGO VAN",
"CITY EXPRESS LT CARGO VAN",
"EXPRESS 1500",
"EXPRESS 1500 CARGO VAN",
"EXPRESS 1500 LS",
"EXPRESS 1500 LT",
"EXPRESS 2500 CARGO VAN",
"EXPRESS 2500 CARGO VAN EXT",
"EXPRESS 2500 LS",
"EXPRESS 2500 LT",
"EXPRESS 3500",
"EXPRESS 3500 CARGO VAN",
"EXPRESS 3500 CARGO VAN EXT",
"EXPRESS 3500 EXT",
"EXPRESS 3500 LS",
"EXPRESS 3500 LS EXT",
"EXPRESS 3500 LT",
"EXPRESS 3500 LT EXT",
"G3500 EXPRESS CUTAWAY",
"SAVANA 1500 CARGO VAN",
"SAVANA 1500 SL",
"SAVANA 1500 SLE",
"SAVANA 2500",
"2500 SAVANA",
"SAVANA 2500 CARGO VAN",
"SAVANA 2500 CARGO VAN EXT",
"SAVANA 2500 LT",
"SAVANA 2500 SLE",
"SAVANA 3500",
"SAVANA 3500 CARGO VAN",
"SAVANA 3500 CARGO VAN EXT",
"SAVANA 3500 EXT",
"SAVANA 3500 LT EXT",
"SAVANA 3500 SLE EXT",
"SAVANA G3500 CUTAWAY",
"SAVANA G4500 CUTAWAY",
"EXPRESS 1500 LS CARGO VAN",
"G20 SPORTVAN",
"NV 3500 S V8 CARGO VAN",
"E-150",
"E-250",
"E-350",
"E-450",
"E150",
"E250",
"E350",
"TRANSIT",
"CITY",
"CITY EXPRESS",
"EXPRESS",
"EXPRESS 2500",
"G3500",
"SAVANA",
"SAVANA 1500",
"CHEVY EXPRESS G2500",
"CLUBWAGON E350",
"TRANSIT CONNECT",
"SPRINTER 2500",
"TRANSIT 150",
"ECONOLINE E250",
"TRANSIT 250",
"ECONOLINE E350",
"NV3500 HD",
"TRANSIT 350HD",
"ECONOLINE E150",
"E250 ECONOLINE",
"C/V",
"E350 CHSCAB",
"G1500 CHEVY EXPRESS",
"2500 SPRINTER",
"E150 ECONOLINE",
"350 TRANSIT",
"E450 CUTAWAY",
"PROMASTER 3500",
"CHEVY EXPRESS G3500",
"SAVANA G3500",
"1500 PROMASTER",
"2500 EXPRESS",
"3500 EXPRESS",
"3500 SPRINTER"
]

View File

@@ -0,0 +1,33 @@
[
"GRAND CARAVAN",
"GRANDCARAVAN",
"GRAND CARAVAN CREW",
"GRAND CARAVAN CV",
"GRAND CARAVAN CVP",
"GRAND CARAVAN SE",
"GRAND CARAVAN SXT",
"CARAVAN CV",
"SIENNA CE V6",
"SIENNA LE V6",
"SIENNA XLE V6",
"SIENNA",
"ODYSSEY",
"SEDONA",
"PACIFICA (NEW)",
"QUEST",
"CARAVAN",
"MONTANA SV6",
"FREESTAR",
"UPLANDER",
"MONTANA",
"VOYAGER",
"ENTOURAGE",
"PACIFICA",
"CARNIVAL",
"VENTURE",
"SAFARI",
"VANAGON",
"WINDSTAR",
"TOWN&COUNTRY",
"ROUTAN"
]

View File

@@ -0,0 +1,485 @@
[
"EDGE SEL",
"ESCAPE",
"ESCAPE SE",
"ESCAPE SEL",
"ESCAPE XLT V6",
"EXPEDITION",
"EXPEDITION LIMITED",
"EXPEDITION MAX",
"EXPEDITION MAX LIMITED",
"EXPLORER",
"EXCURSION",
"EXPLORER LIMITED",
"EXPLORER PLATINUM ECOBOOST",
"EXPLORER XLT",
"FLEX",
"FLEX SE",
"ECOSPORT",
"ESCAPE HYBRID",
"MUSTANG MACH-E",
"BRONCO",
"BRONCO SPORT",
"TRAILBLAZER",
"BLAZER LT",
"CHEROKEE",
"CHEROKEE CLASSIC",
"CHEROKEE COUNTRY",
"CHEROKEE LIMITED",
"CHEROKEE NORTH",
"CHEROKEE OVERLAND",
"CHEROKEE SPORT",
"CHEROKEE TRAILHAWK",
"CJ",
"CJ7",
"CJ7 RENEGADE",
"COMMANDER",
"COMMANDER LIMITED",
"COMMANDER SPORT",
"COMPASS",
"COMPASS HIGH ALTITUDE",
"COMPASS LATITUDE",
"COMPASS LIMITED",
"COMPASS NORTH",
"COMPASS SPORT",
"COMPASS TRAILHAWK",
"GLADIATOR OVERLAND",
"GLADIATOR RUBICON",
"GRAND CHEROKEE LAREDO",
"GRAND CHEROKEE LIMITED",
"GRAND CHEROKEE OVERLAND",
"GRAND CHEROKEE SE",
"GRAND CHEROKEE SRT",
"GRAND CHEROKEE SRT8",
"GRAND CHEROKEE SUMMIT",
"GRAND CHEROKEE TRACKHAWK",
"GRAND CHEROKEE TRAILHAWK",
"GRAND CHEROKEE",
"GRANDCHEROKEE",
"LIBERTY LIMITED",
"LIBERTY RENEGADE",
"LIBERTY SPORT",
"LIBERTY",
"PATRIOT",
"PATRIOT HIGH ALTITUDE",
"PATRIOT LATITUDE",
"PATRIOT LIMITED",
"PATRIOT NORTH",
"PATRIOT SPORT",
"RENEGADE LIMITED",
"RENEGADE NORTH",
"RENEGADE SPORT",
"RENEGADE TRAILHAWK",
"TJ",
"TJ RUBICON",
"TJ SAHARA",
"TJ SPORT",
"TJ UNLIMITED",
"WRANGLER",
"WRANGLER RUBICON",
"WRANGLER SAHARA",
"WRANGLER SPORT",
"WRANGLER UNLIMITED",
"WRANGLER UNLIMITED 70TH ANNIV",
"WRANGLER UNLIMITED RUBICON",
"WRANGLER UNLIMITED SAHARA",
"WRANGLER UNLIMITED SPORT",
"WRANGLER UNLIMITED X",
"WRANGLER X",
"YJ WRANGLER",
"AVIATOR",
"AVIATOR RESERVE",
"MKC",
"MKC RESERVE",
"MKC SELECT",
"MKT",
"MKT ECOBOOST",
"MKX",
"MKX RESERVE",
"NAUTILUS RESERVE",
"NAUTILUS RESERVE V6",
"NAVIGATOR",
"NAVIGATOR L",
"NAVIGATOR L RESERVE",
"NAVIGATOR L SELECT",
"NAVIGATOR RESERVE",
"PILOT",
"PILOT BLACK EDITION",
"PILOT ELITE",
"PILOT EX",
"PILOT EX-L",
"PILOT GRANITE",
"PILOT LX",
"PILOT SE",
"PILOT SE-L",
"PILOT TOURING",
"DURANGO R/T",
"DURANGO SLT PLUS",
"DURANGO SRT",
"DURANGO",
"JOURNEY",
"JOURNEY CROSSROAD",
"JOURNEY CVP",
"JOURNEY LIMITED",
"JOURNEY R/T",
"JOURNEY SXT",
"NITRO SE",
"NITRO",
"K1500 SUBURBAN",
"SUBURBAN 1500 LT",
"SUBURBAN 1500 LTZ",
"SUBURBAN 1500 PREMIER",
"SUBURBAN 2500 LS",
"TAHOE LT",
"TRAVERSE LS",
"TRAVERSE LT",
"TRAVERSE PREMIER",
"TRAX LT",
"TRAX PREMIER",
"UPLANDER LT EXT",
"SUBURBAN",
"TAHOE",
"TRAVERSE",
"TRAX",
"UPLANDER",
"YUKON",
"YUKON DENALI",
"YUKON XL",
"YUKON XL DENALI",
"EQUINOX LS",
"EQUINOX LT",
"EQUINOX PREMIER",
"EQUINOX",
"RAV4 LE",
"RAV4 XLE",
"HIGHLANDER SPORT V6",
"4RUNNER SR5 V6",
"RAV4",
"RAV4 HYBRID",
"RAV4 XLE HYBRID",
"HIGHLANDER",
"4RUNNER",
"SEQUOIA",
"PATHFINDER SE",
"PATHFINDER SL",
"PATHFINDER",
"MURANO PLATINUM",
"MURANO SV",
"MURANO",
"TUCSON",
"TERRAIN",
"SORENTO",
"EDGE",
"KICKS",
"QASHQAI",
"SANTA FE",
"ARMADA",
"TELLURIDE",
"PALISADE",
"SELTOS",
"TORRENT",
"C-HR",
"SPORTAGE",
"VENZA",
"ACADIA",
"CR-V",
"HR-V",
"CX-5",
"CX-50",
"CX-7",
"CX-9",
"CX-3",
"Q3",
"Q5",
"Q7",
"Q8",
"JUKE SV",
"JUKE",
"ROGUE",
"ROGUE SV",
"XTERRA",
"COROLLA CROSS",
"ACADIA DENALI",
"TAURUS X",
"MACAN",
"FJ CRUISER",
"BRONCO SPORT BADLANDS",
"ESCALADE",
"RX 350",
"KONA",
"MDX",
"RDX",
"COOPER COUNTRYMAN",
"V70",
"OUTLANDER",
"RIO5",
"GLC300 COUPE",
"ENCORE",
"SRX",
"SANTA FE SPORT",
"NX 300",
"WRANGLER UNLIMITE",
"WRANGLER JK UNLIM",
"RANGEROVER EVOQUE",
"CROSSTREK",
"FORESTER",
"TIGUAN",
"XV CROSSTREK",
"ENDEAVOR",
"RX 330",
"ATLAS",
"XC90",
"TOUAREG",
"STELVIO",
"RANGE ROVER SPORT",
"GLE350D",
"EX35",
"RVR",
"MONTERO",
"X-TRAIL",
"GRAND VITARA",
"TRIBUTE",
"X3",
"XC60",
"GLK250 BLUETEC",
"ENVOY",
"ML350 BLUETEC",
"ENVISION",
"FX35",
"X1",
"VENUE",
"TAOS",
"KONA ELECTRIC",
"OUTLANDER PHEV",
"PASSPORT",
"H3",
"EXPLORERSPORTTRAC",
"F-PACE",
"ML320 BLUETEC",
"REGAL SPORTBACK",
"DISCOVERY SPORT",
"RENDEZVOUS",
"XC70",
"COMPASS (NEW)",
"CUBE",
"V60 CROSS COUNTRY",
"QX70",
"X6",
"ELEMENT",
"RX 400H",
"VUE",
"RANGE ROVER VELAR",
"E-PACE",
"RAV4 PRIME",
"LX 570",
"GX 470",
"EX37",
"GLE43",
"NAUTILUS",
"XT6",
"RX 450H",
"ESCALADE ESV",
"OUTLOOK",
"CAYENNE",
"XC90 PLUG-IN",
"MODEL X",
"MODEL Y",
"GLC300",
"SANTA FE HYBRID",
"G63",
"XV CROSSTREK HYBR",
"JX35",
"JIMMY",
"TUCSON HYBRID",
"XC40 ELECTRIC",
"RX 300",
"ML320",
"WRANGLER JK UNLIMITED",
"POLICE INTERCEPTOR UTILITY",
"WRANGLER JK",
"TRIBECA",
"E-TRON SPORTBACK",
"500X",
"RX 350H",
"GL350 BLUETEC",
"WRANGLER UNLIMITED 4XE",
"GV80",
"GL550",
"Q5 E",
"H2 SUV",
"Q5 HYBRID",
"IONIQ 5",
"SQ5 SPORTBACK",
"LEVANTE",
"TONALE",
"GLE43 COUPE",
"GRAND CHEROKEE WK",
"DEFENDER",
"NX 450H+",
"ML400",
"LX 600",
"RX 450HL",
"SORENTO HYBRID",
"NX 350",
"TRACKER",
"GLE450",
"Q5 SPORTBACK",
"CR-V HYBRID",
"LX 470",
"EQS580 SUV",
"H2",
"EV9",
"SORENTO PLUG-IN",
"LYRIQ",
"GLE550",
"RX 500H",
"X1 SAV",
"E-TRON S SPORTBACK",
"ML500",
"GRAND HIGHLANDER HYBRID",
"RS Q8",
"GLS550",
"GLS580",
"IX",
"CAYENNE COUPE",
"SOLTERRA",
"PATHFINDER HYBRID",
"Q8 E-TRON",
"TX 350",
"TX 500H",
"EQUINOX EV",
"NAUTILUS HYBRID",
"TRAVERSE LIMITED",
"CX-70",
"SANTA FE XL",
"RENEGADE",
"QX50",
"ECLIPSE CROSS",
"QX80",
"X5",
"X3",
"X1",
"X4",
"ENCLAVE",
"ENCORE GX",
"CAYENNE HYBRID",
"SOUL",
"GX 460",
"UX 250H",
"XT5",
"GLE53",
"XT4",
"SQ7",
"NX 350H",
"GLK350",
"GLE350",
"NX 300H",
"NX 200T",
"RANGE ROVER EVOQUE",
"GLS450",
"TERRAIN DENALI",
"GRAND CHEROKEE L",
"GLE400",
"TUCSON PLUG-IN",
"BLAZER",
"ASCENT",
"HIGHLANDER HYBRID",
"ATLAS CROSS SPORT",
"XC40",
"VENZA HYBRID",
"GLA45",
"GLB250",
"GRAND HIGHLANDER",
"GV70",
"NIRO",
"NIRO EV",
"GLA250",
"ESCAPE PLUG-IN",
"WAGONEER",
"CX-30",
"QX60",
"GRAND CHEROKEE 4XE",
"SPORTAGE HYBRID",
"EV6",
"TONALE PLUG-IN",
"GLC43 COUPE",
"X2",
"RX 350L",
"HORNET",
"ENVISTA",
"LEVANTE S",
"SPORTAGE PLUG-IN",
"ORLANDO",
"X5 M",
"EXPLORER HYBRID",
"FREESTYLE",
"CORSAIR",
"K1500 YUKON XL",
"RANGE ROVER",
"SUV W/O LABOR",
"ID.4",
"CX-90",
"X7",
"CORSAIR PLUG-IN",
"ESCALADE EXT",
"QX55",
"DISCOVERY",
"BOLT EUV",
"C40 ELECTRIC",
"LR4",
"GRAND WAGONEER",
"XC60 PLUG-IN",
"LR2",
"EQE350 SUV",
"COROLLA CROSS HYBRID",
"SOUL EV",
"GRECALE",
"SUV W/O LABOR",
"QX30",
"SQ5",
"NIRO PLUG-IN",
"BORREGO",
"CX-90 PLUG-IN",
"XL-7",
"SUV W/O LABOR",
"SUV W/O LABOR",
"I-PACE",
"HORNET PLUG-IN",
"UX 300H",
"ML320 CDI",
"VERACRUZ",
"SQ8",
"GLE53 COUPE",
"ZDX",
"9-7X",
"ARIYA",
"ASPEN",
"AVIATOR PLUG-IN",
"B9 TRIBECA",
"BRAVADA",
"ENVOY XL",
"EQB350",
"EQB350 SUV",
"ESCALADE-V",
"E-TRON",
"FX37",
"GL320 CDI",
"GLADIATOR",
"GLC43",
"GLE450 COUPE",
"GLE63",
"GV60",
"MKT TOWN CAR",
"ML350",
"ML550",
"ML63",
"NX 250",
"Q4 E-TRON",
"Q8 E-TRON SPORTBACK",
"QX4",
"QX56",
"SANTA FE PLUG-IN",
"UX 200",
"WAGONEER L",
"XB"
]

View File

@@ -0,0 +1,567 @@
[
"MARK LT",
"F-150",
"F-250",
"F-350",
"F-450",
"F-550",
"F-650",
"F100 PICKUP",
"F150 FX2 SUPERCAB",
"F150 FX4 PICKUP",
"F150 FX4 SUPERCAB",
"F150 FX4 SUPERCREW",
"F150 HARLEY DAVIDSON SUPERCAB",
"F150 HARLEY DAVIDSON SUPERCREW",
"F150 KING RANCH SUPERCREW",
"F150 LARIAT FX4 SUPERCREW",
"F150 LARIAT HARLEY DAVIDSON SC",
"F150 LARIAT KING RANCH SUPCREW",
"F150 LARIAT LIMITED SUPERCREW",
"F150 LARIAT PICKUP",
"F150 LARIAT SUPERCAB",
"F150 LARIAT SUPERCAB (AMALGAM)",
"F150 LARIAT SUPERCREW",
"F150 LARIAT SUPERCREW (AMALGA)",
"F150 LIMITED SUPERCREW",
"F150 PICKUP",
"F150 PLATINUM SUPERCREW",
"F150 RAPTOR SUPERCAB",
"F150 RAPTOR SUPERCREW",
"F150 STX PICKUP",
"F150 STX SUPERCAB",
"F150 SUPERCAB",
"F150 SUPERCREW",
"F150 SUPERCREW (AMALGAMATED)",
"F150 SVT RAPTOR SUPERCAB",
"F150 XL PICKUP",
"F150 XL SUPERCAB",
"F150 XL SUPERCREW",
"F150 XLT LARIAT SUPERCAB",
"F150 XLT PICKUP",
"F150 XLT SUPERCAB",
"F150 XLT SUPERCREW",
"F150 XLT SUPERCREW (AMALGAMAT)",
"F150 XTR SUPERCAB",
"F250 PICKUP",
"F250 SD CREW CAB",
"F250 SD FX4 CREW CAB",
"F250 SD FX4 SUPERCAB",
"F250 SD KING RANCH CREW CAB",
"F250 SD LARIAT CREW CAB",
"F250 SD LARIAT CREW CAB (AMAL)",
"F250 SD LARIAT PICKUP",
"F250 SD LARIAT SUPERCAB",
"F250 SD LIMITED CREW CAB",
"F250 SD PLATINUM CREW CAB",
"F250 SD SUPERCAB",
"F250 SD XL CREW CAB",
"F250 SD XL PICKUP",
"F250 SD XL SUPERCAB",
"F250 SD XLT CREW CAB",
"F250 SD XLT PICKUP",
"F250 SD XLT SUPERCAB",
"F250 SUPERCAB",
"F250 XL CREW CAB",
"F350 CREW CAB",
"F350 PICKUP",
"F350 PICKUP 2WD",
"F350 SD CABELAS CREW CAB",
"F350 SD CREW CAB",
"F350 SD FX4 CREW CAB",
"F350 SD FX4 SUPERCAB",
"F350 SD HARLEY DAVIDSON",
"F350 SD KING RANCH CREW CAB",
"F350 SD LARIAT CREW CAB",
"F350 SD LARIAT CREW CAB (AMAL)",
"F350 SD LARIAT KING RANCH",
"F350 SD LARIAT SUPERCAB",
"F350 SD LIMITED CREW CAB",
"F350 SD PICKUP",
"F350 SD PLATINUM CREW CAB",
"F350 SD SUPERCAB",
"F350 SD XL CREW CAB",
"F350 SD XL PICKUP",
"F350 SD XL SUPERCAB",
"F350 SD XLT CREW CAB",
"F350 SD XLT SUPERCAB",
"F350 SUPER DUTY",
"F350 SUPER DUTY XL",
"F350 XL PICKUP",
"F450",
"F450 Pickup",
"F450 SD KING RANCH CREW CAB",
"F450 SD LARIAT CREW CAB",
"F450 SD PICKUP",
"F450 SD PLATINUM CREW CAB",
"F450 SD XL",
"F450 SD XL CREW CAB",
"F450 SD XL PICKUP",
"F450 SD XLT CREW CAB",
"F450 SUPER DUTY XLT",
"F550",
"F550 SD",
"F550 SD XL",
"F550 SD XL PICKUP",
"F550 SD XLT CREW CAB",
"F550 SD XLT SUPERCAB",
"F550 SUPER DUTY",
"F550 SUPER DUTY XL",
"F550 SUPER DUTY XLT",
"F550 SUPER DUTY XLT CREW CAB",
"F550 XL",
"F650 SD XLT SUPERCAB",
"F68",
"F750 XL",
"RANGER",
"RANGER EDGE SUPERCAB",
"RANGER FX4 SUPERCAB",
"RANGER LARIAT SUPERCREW",
"RANGER SPORT SUPERCAB",
"RANGER STX SUPERCAB",
"RANGER SUPERCAB",
"RANGER XL",
"RANGER XL SUPERCAB",
"RANGER XLT",
"RANGER XLT SUPERCAB",
"RANGER XLT SUPERCREW",
"FRONTIER LE CREW CAB V6",
"FRONTIER NISMO CREW CAB V6",
"FRONTIER NISMO KING CAB V6",
"FRONTIER PRO-4X CREW CAB V6",
"FRONTIER PRO-4X KING CAB V6",
"FRONTIER S KING CAB",
"FRONTIER SC CREW CAB V6",
"FRONTIER SC V6",
"FRONTIER SE CREW CAB V6",
"FRONTIER SE KING CAB V6",
"FRONTIER SL CREW CAB V6",
"FRONTIER SV CREW CAB V6",
"FRONTIER SV KING CAB V6",
"FRONTIER XE KING CAB",
"FRONTIER XE KING CAB V6",
"KING CAB",
"TITAN 5.6 LE CREW CAB",
"TITAN 5.6 LE KING CAB",
"TITAN 5.6 MIDNIGHT CREW CAB",
"TITAN 5.6 PLATINUM RESERVE CC",
"TITAN 5.6 PRO-4X CREW CAB",
"TITAN 5.6 PRO-4X KING CAB",
"TITAN 5.6 S CREW CAB",
"TITAN 5.6 SE CREW CAB",
"TITAN 5.6 SE KING CAB",
"TITAN 5.6 SL CREW CAB",
"TITAN 5.6 SV CREW CAB",
"TITAN 5.6 SV KING CAB",
"TITAN 5.6 XE CREW CAB",
"TITAN 5.6 XE KING CAB",
"TITAN XD PLATINUM CREW CAB",
"TITAN XD PRO-4X CREW CAB",
"TITAN XD S CREW CAB",
"TITAN XD SL CREW CAB",
"TITAN XD SV CREW CAB",
"PICKUP SR5",
"TACOMA",
"TACOMA ACCESS CAB",
"TACOMA DOUBLE CAB V6",
"TACOMA LIMITED DOUBLE CAB V6",
"TACOMA PRERUNNER DOUBLE CAB V6",
"TACOMA PRERUNNER V6 ACCESS CAB",
"TACOMA PRERUNNER XTRACAB",
"TACOMA PRERUNNER XTRACAB V6",
"TACOMA SR5 DOUBLE CAB V6",
"TACOMA SR5 V6 ACCESS CAB",
"TACOMA SR5 V6 XTRACAB",
"TACOMA V6 ACCESS CAB",
"TACOMA XTRACAB",
"TACOMA XTRACAB V6",
"TUNDRA ACCESS CAB V8",
"TUNDRA DOUBLE CAB V8",
"TUNDRA LIMITED ACCESS CAB V8",
"TUNDRA LIMITED SR5 DBLCAB V8",
"TUNDRA LIMITED V8",
"TUNDRA LIMITED V8 CREWMAX",
"TUNDRA LIMITED V8 DOUBLE CAB",
"TUNDRA PLATINUM V8 CREWMAX",
"TUNDRA SR DOUBLE CAB V8",
"TUNDRA SR V8",
"TUNDRA SR5 DOUBLE CAB V8",
"TUNDRA SR5 TRD DOUBLE CAB V8",
"TUNDRA SR5 V8 CREWMAX",
"TUNDRA V8",
"TUNDRA V8 CREWMAX",
"XTRACAB LONG BOX",
"AVALANCHE 1500",
"AVALANCHE 1500 LS",
"AVALANCHE 1500 LS Z71",
"AVALANCHE 1500 LT",
"AVALANCHE 1500 LT Z71",
"AVALANCHE 1500 LTZ",
"C/R 10/1500 4+CAB",
"C/R 10/1500 PICKUP",
"C/R 20/2500 4+CAB",
"C/R 20/2500 PICKUP",
"C3500",
"COLORADO",
"COLORADO EXT CAB",
"COLORADO LS",
"COLORADO LS CREW CAB",
"COLORADO LS EXT CAB",
"COLORADO LT",
"COLORADO LT CREW CAB",
"COLORADO LT EXT CAB",
"COLORADO WT CREW CAB",
"COLORADO WT EXT CAB",
"COLORADO Z71 CREW CAB",
"COLORADO Z71 EXT CAB",
"COLORADO ZR2 CREW CAB",
"COLORADO ZR2 EXT CAB",
"HHR LS PANEL",
"K/V 10/1500 4+CAB",
"K/V 10/1500 PICKUP",
"K/V 20/2500 4+CAB",
"K/V 20/2500 PICKUP",
"K/V 30/3500 4+CAB",
"Pickup K3500",
"Pickup Silverado C2500 HD",
"S10 4+CAB",
"S10 LS 4+CAB",
"SILVERADO 1500",
"SILVERADO 1500 CHEYENNE CREW",
"SILVERADO 1500 CREW CAB",
"SILVERADO 1500 CREW CAB (AMAL)",
"SILVERADO 1500 CUST TRAIL DC",
"SILVERADO 1500 CUSTOM CREW CAB",
"SILVERADO 1500 CUSTOM DC",
"SILVERADO 1500 CUSTOM TRAIL CC",
"SILVERADO 1500 DOUBLE (AMALGA)",
"SILVERADO 1500 EXT CAB",
"SILVERADO 1500 HD LS CREW CAB",
"SILVERADO 1500 HD LT CREW CAB",
"SILVERADO 1500 HIGH COUNTRY CC",
"SILVERADO 1500 HYBRID CREW CAB",
"SILVERADO 1500 LS",
"SILVERADO 1500 LS CREW CAB",
"SILVERADO 1500 LS DOUBLE CAB",
"SILVERADO 1500 LS EXT CAB",
"SILVERADO 1500 LT",
"SILVERADO 1500 LT CC (AMALGAM)",
"SILVERADO 1500 LT CREW CAB",
"SILVERADO 1500 LT DOUBLE CAB",
"SILVERADO 1500 LT EXT CAB",
"SILVERADO 1500 LT TRAIL CC",
"SILVERADO 1500 LT TRAIL DC",
"SILVERADO 1500 LTZ CREW CAB",
"SILVERADO 1500 LTZ DOUBLE CAB",
"SILVERADO 1500 LTZ EXT CAB",
"SILVERADO 1500 RST CREW CAB",
"SILVERADO 1500 RST DOUBLE CAB",
"SILVERADO 1500 SS EXT CAB",
"SILVERADO 1500 WT",
"SILVERADO 1500 WT CREW CAB",
"SILVERADO 1500 WT DOUBLE CAB",
"SILVERADO 1500 WT EXT CAB",
"SILVERADO 2500 EXT CAB",
"SILVERADO 2500 HD",
"SILVERADO 2500 HD CREW CAB",
"SILVERADO 2500 HD EXT CAB",
"SILVERADO 2500 HD HC CREW CAB",
"SILVERADO 2500 HD LS CREW CAB",
"SILVERADO 2500 HD LS EXT CAB",
"SILVERADO 2500 HD LT",
"SILVERADO 2500 HD LT CREW CAB",
"SILVERADO 2500 HD LT DBL CAB",
"SILVERADO 2500 HD LT EXT CAB",
"SILVERADO 2500 HD LTZ CREW CAB",
"SILVERADO 2500 HD LTZ DBL CAB",
"SILVERADO 2500 HD LTZ EXT CAB",
"SILVERADO 2500 HD WT",
"SILVERADO 2500 HD WT CREW CAB",
"SILVERADO 2500 HD WT DBL CAB",
"SILVERADO 2500 HD WT EXT CAB",
"SILVERADO 3500",
"SILVERADO 3500 CREW CAB",
"SILVERADO 3500 CREW CAB (AMAL)",
"SILVERADO 3500 EXT CAB",
"SILVERADO 3500 HC CREW CAB",
"SILVERADO 3500 HD (AMALGAMATE)",
"SILVERADO 3500 LS",
"SILVERADO 3500 LS CREW CAB",
"SILVERADO 3500 LS EXT CAB",
"SILVERADO 3500 LT CREW CAB",
"SILVERADO 3500 LT DOUBLE CAB",
"SILVERADO 3500 LT EXT CAB",
"SILVERADO 3500 LTZ CREW CAB",
"SILVERADO 3500 LTZ EXT CAB",
"SILVERADO 3500 WT CREW CAB",
"Silverado 3500HD",
"B250 SPORTSMAN",
"DAKOTA CLUB CAB",
"DAKOTA LARAMIE V8 CLUB CAB",
"DAKOTA LARAMIE V8 QUAD CAB",
"DAKOTA QUAD CAB",
"DAKOTA SLT CREW CAB",
"DAKOTA SLT EXT CAB",
"DAKOTA SLT PLUS QUAD CAB",
"DAKOTA SLT PLUS V8 CLUB CAB",
"DAKOTA SLT PLUS V8 QUAD CAB",
"DAKOTA SLT QUAD CAB",
"DAKOTA SLT V8 CLUB CAB",
"DAKOTA SLT V8 CREW CAB",
"DAKOTA SLT V8 EXT CAB",
"DAKOTA SLT V8 QUAD CAB",
"DAKOTA SPORT V8",
"DAKOTA SPORT V8 CLUB CAB",
"DAKOTA SPORT V8 QUAD CAB",
"DAKOTA ST CLUB CAB",
"DAKOTA ST QUAD CAB",
"DAKOTA ST V8 QUAD CAB",
"DAKOTA SXT CREW CAB",
"DAKOTA SXT EXT CAB",
"DAKOTA SXT V8 CREW CAB",
"DAKOTA SXT V8 EXT CAB",
"DAKOTA V8 CLUB CAB",
"DAKOTA V8 QUAD CAB",
"RAM 1500",
"RAM 1500 BIG HORN CREW CAB",
"RAM 1500 BIG HORN QUAD CAB",
"RAM 1500 CLUB CAB",
"RAM 1500 CREW CAB (AMALGAMATE)",
"RAM 1500 EXPRESS",
"RAM 1500 LARAMIE CREW (AMALGA)",
"RAM 1500 LARAMIE CREW CAB",
"RAM 1500 LARAMIE LONGHORN CREW",
"RAM 1500 LARAMIE MEGA CAB",
"RAM 1500 LARAMIE QUAD CAB",
"RAM 1500 LARAMIE SLT QUAD CAB",
"RAM 1500 LIMITED CREW CAB",
"RAM 1500 LONGHORN CREW CAB",
"RAM 1500 OUTDOORSMAN CREW CAB",
"RAM 1500 OUTDOORSMAN QC (AMAL)",
"RAM 1500 OUTDOORSMAN QUAD CAB",
"RAM 1500 QUAD CAB",
"RAM 1500 R/T",
"RAM 1500 REBEL CREW CAB",
"RAM 1500 REBEL QUAD CAB",
"RAM 1500 SLT",
"RAM 1500 SLT CREW (AMALGAMATE)",
"RAM 1500 SLT CREW CAB",
"RAM 1500 SLT MEGA CAB",
"RAM 1500 SLT QUAD (AMALGAMATE)",
"RAM 1500 SLT QUAD CAB",
"RAM 1500 SPORT",
"RAM 1500 SPORT CLUB CAB",
"RAM 1500 SPORT CREW CAB",
"RAM 1500 SPORT CREW CAB (AMAL)",
"RAM 1500 SPORT QUAD CAB",
"RAM 1500 ST",
"RAM 1500 ST CREW CAB",
"RAM 1500 ST QUAD CAB",
"RAM 1500 SXT CREW CAB",
"RAM 1500 SXT QUAD CAB",
"RAM 1500 TRADESMAN CREW CAB",
"RAM 1500 TRADESMAN QUAD CAB",
"RAM 1500 TRX QUAD CAB",
"RAM 2500",
"RAM 2500 BIG HORN CREW CAB",
"RAM 2500 BIG HORN MEGA CAB",
"RAM 2500 CLUB CAB",
"RAM 2500 LARAMIE CREW CAB",
"RAM 2500 LARAMIE LONGHORN CREW",
"RAM 2500 LARAMIE LONGHORN MEGA",
"RAM 2500 LARAMIE MEGA CAB",
"RAM 2500 LARAMIE QUAD CAB",
"RAM 2500 LARAMIE SLT",
"RAM 2500 LARAMIE SLT QUAD CAB",
"RAM 2500 LIMITED CREW CAB",
"RAM 2500 OUTDOORSMAN CREW CAB",
"RAM 2500 POWER WAGON CREW CAB",
"RAM 2500 QUAD CAB",
"RAM 2500 SLT",
"RAM 2500 SLT CREW CAB",
"RAM 2500 SLT MEGA CAB",
"RAM 2500 SLT QUAD CAB",
"RAM 2500 SLT QUAD CAB (AMALGA)",
"RAM 2500 SPORT QUAD CAB",
"RAM 2500 ST",
"RAM 2500 ST CREW CAB",
"RAM 2500 ST QUAD CAB",
"RAM 2500 SXT QUAD CAB",
"RAM 2500 TRADESMAN",
"RAM 2500 TRADESMAN CREW CAB",
"RAM 2500 TRX CREW CAB",
"RAM 2500 TRX QUAD CAB",
"RAM 3500",
"RAM 3500 4WD",
"RAM 3500 BIG HORN CREW CAB",
"RAM 3500 CREW CAB",
"RAM 3500 CREW CAB (AMALGAMATE)",
"RAM 3500 LARAMIE CREW CAB",
"RAM 3500 LARAMIE LONGHORN CREW",
"RAM 3500 LARAMIE LONGHORN MEGA",
"RAM 3500 LARAMIE MEGA CAB",
"RAM 3500 LARAMIE QUAD CAB",
"RAM 3500 LARAMIE SLT",
"RAM 3500 LARAMIE SLT QUAD CAB",
"RAM 3500 LIMITED MEGA CAB",
"RAM 3500 LONGHORN CREW CAB",
"RAM 3500 QUAD CAB",
"RAM 3500 SLT",
"RAM 3500 SLT CREW CAB",
"RAM 3500 SLT MEGA CAB",
"RAM 3500 SLT QUAD CAB",
"RAM 3500 SPORT QUAD CAB",
"RAM 3500 ST",
"RAM 3500 ST CREW CAB",
"RAM 3500 ST QUAD CAB",
"RAM 3500 TRX QUAD CAB",
"RAM 4500",
"RAM 4500 CREW CAB",
"RAM 5500",
"RAM 5500 CREW CAB",
"W250 TURBO DIESEL",
"C Series 5500",
"C/R 1500 4+CAB",
"C/R 1500 PICKUP",
"C/R 1500 SIERRA SL EXT CAB",
"C/R 3500",
"C/R 3500 PICKUP",
"CANYON ALL TERRAIN CREW CAB",
"CANYON CREW CAB",
"CANYON DENALI CREW CAB",
"CANYON EXT CAB",
"CANYON SL",
"CANYON SL EXT CAB",
"CANYON SLE",
"CANYON SLE CREW CAB",
"CANYON SLE EXT CAB",
"CANYON SLT CREW CAB",
"CANYON SLT CREW CAB (AMALGAMA)",
"K/V 1500 4+CAB",
"K/V 1500 PICKUP",
"K/V 2500 4+CAB",
"K/V 2500 PICKUP",
"K/V 3500 SIERRA SL CREW CAB",
"K/V 3500 SIERRA SLE CREW CAB",
"SIERRA 1500 AT4 CREW CAB",
"SIERRA 1500 AT4 DOUBLE CAB",
"SIERRA 1500 CREW CAB",
"SIERRA 1500 CREW CAB (AMALGAM)",
"SIERRA 1500 DENALI CREW CAB",
"SIERRA 1500 DENALI EXT CAB",
"SIERRA 1500 DOUBLE CAB",
"SIERRA 1500 ELEVATION CREW CAB",
"SIERRA 1500 ELEVATION DC",
"SIERRA 1500 EXT CAB",
"SIERRA 1500 HD CREW CAB",
"SIERRA 1500 HD SLE CREW CAB",
"SIERRA 1500 HD SLT CREW CAB",
"SIERRA 1500 NEVADA EDITION",
"SIERRA 1500 PICKUP",
"SIERRA 1500 SL CREW CAB",
"SIERRA 1500 SL EXT CAB",
"SIERRA 1500 SL PICKUP",
"SIERRA 1500 SLE CREW CAB",
"SIERRA 1500 SLE DC (AMALGAMAT)",
"SIERRA 1500 SLE DOUBLE CAB",
"SIERRA 1500 SLE EXT CAB",
"SIERRA 1500 SLE EXT CAB (AMAL)",
"SIERRA 1500 SLE PICKUP",
"SIERRA 1500 SLT CREW (AMALGAM)",
"SIERRA 1500 SLT CREW CAB",
"SIERRA 1500 SLT DOUBLE CAB",
"SIERRA 1500 SLT EXT CAB",
"SIERRA 1500 WT CREW CAB",
"SIERRA 1500 WT EXT CAB",
"SIERRA 1500 WT PICKUP",
"SIERRA 2500 EXT CAB",
"SIERRA 2500 HD AT4 CREW CAB",
"SIERRA 2500 HD CREW CAB",
"SIERRA 2500 HD DENALI CREW CAB",
"SIERRA 2500 HD DOUBLE CAB",
"SIERRA 2500 HD EXT CAB",
"SIERRA 2500 HD PICKUP",
"SIERRA 2500 HD SL EXT CAB",
"SIERRA 2500 HD SL PICKUP",
"SIERRA 2500 HD SLE CREW CAB",
"SIERRA 2500 HD SLE DOUBLE CAB",
"SIERRA 2500 HD SLE EXT CAB",
"SIERRA 2500 HD SLE PICKUP",
"SIERRA 2500 HD SLT CREW CAB",
"SIERRA 2500 HD SLT DOUBLE CAB",
"SIERRA 2500 HD SLT EXT CAB",
"SIERRA 2500 HD WT CREW CAB",
"SIERRA 2500 HD WT DOUBLE CAB",
"SIERRA 2500 HD WT EXT CAB",
"SIERRA 2500 HD WT PICKUP",
"SIERRA 2500 SLE EXT CAB",
"SIERRA 3500 AT4 CREW CAB",
"SIERRA 3500 CREW CAB",
"SIERRA 3500 DENALI CREW CAB",
"SIERRA 3500 EXT CAB",
"SIERRA 3500 PICKUP",
"SIERRA 3500 SL CREW CAB",
"SIERRA 3500 SLE",
"SIERRA 3500 SLE CREW CAB",
"SIERRA 3500 SLE EXT CAB",
"SIERRA 3500 SLT CREW CAB",
"SIERRA 3500 WT CREW CAB",
"SONOMA",
"SONOMA CREW CAB",
"SONOMA EXT CAB",
"1500",
"1500 Classic",
"Pickup 1500",
"Pickup 3500",
"ProMaster 1500",
"RIDGELINE",
"RIDGELINE BLACK EDITION",
"RIDGELINE DX",
"RIDGELINE EX-L",
"RIDGELINE LX",
"RIDGELINE RT",
"RIDGELINE RTL",
"RIDGELINE RTS",
"RIDGELINE RTX",
"RIDGELINE SE",
"RIDGELINE SPORT",
"RIDGELINE TOURING",
"RIDGELINE VP",
"TITAN",
"TACOMA",
"TUNDRA",
"AVALANCE",
"COLORADO",
"SILVERADO",
"SILVERADO 1500",
"SILVERADO 2500",
"SILVERADO 3500",
"DAKOTA",
"RAM 1500",
"RAM 2500",
"RAM 3500",
"RAM 4500",
"RAM 5500",
"CANYON",
"SIERRA 1500",
"SIERRA 2500",
"SIERRA 3500",
"SONOMA",
"1500"
]

View File

@@ -0,0 +1,39 @@
const logger = require("../../utils/logger");
const TrucksList = require("./trucks.json");
const CargoVanList = require("./cargovans.json");
const PassengerVanList = require("./passengervans.json");
const SuvList = require("./suvs.json");
const vehicletype = async (req, res) => {
try {
const { model } = req.body;
if (!model || model.trim() === "") {
res.status(400).json({ success: false, error: "Please provide a model" });
} else {
const type = getVehicleType(model.trim())
res.status(200).json({ success: true, ...type });
}
} catch (error) {
logger.log("vehicletype-error", "ERROR", req?.user?.email, null, {
error: error.message,
stack: error.stack
});
res.status(500).json({ error: error.message, stack: error.stack });
}
};
function getVehicleType(model) {
const inTrucks = TrucksList.includes(model.toUpperCase());
const inPV = PassengerVanList.includes(model.toUpperCase());
const inSuv = SuvList.includes(model.toUpperCase());
const inCv = CargoVanList.includes(model.toUpperCase());
if (inTrucks) return { type: "TK", match: true };
else if (inPV) return { type: "PC", match: true };
else if (inSuv) return { type: "SUV", match: true };
else if (inCv) return { type: "VN", match: true };
else return { type: "PC", match: false };
}
exports.default = vehicletype;

View File

@@ -1854,7 +1854,7 @@ exports.GET_CHATTER_SHOPS = `query GET_CHATTER_SHOPS {
}`;
exports.GET_CARFAX_SHOPS = `query GET_CARFAX_SHOPS {
bodyshops{
bodyshops(where: {external_shop_id: {_is_null: true}}){
id
shopname
imexshopid

View File

@@ -35,6 +35,11 @@
"headerMargin": "135"
},
"md_ro_statuses": {
"parts_statuses": [
"Open",
"In Progress",
"Completed"
],
"statuses": [
"Open",
"Scheduled",
@@ -54,6 +59,10 @@
"Void"
],
"default_void": "Void",
"parts_active_statuses": [
"Open",
"In Progress"
],
"active_statuses": [
"Open",
"Scheduled",
@@ -766,6 +775,7 @@
"csi:page": 11,
"jobs:void": 80,
"shop:rbac": 99,
"shop:responsibilitycenter": 99,
"bills:list": 11,
"bills:view": 11,
"csi:export": 11,

View File

@@ -23,10 +23,16 @@ const KNOWN_PART_RATE_TYPES = [
* @returns {object} The parts tax rates object.
*/
//TODO: Major validation would be required on this - EMS files are inconsistent with things like 5% being passed as 5.0 or .05.
const extractPartsTaxRates = (profile = {}) => {
const rateInfos = Array.isArray(profile.RateInfo) ? profile.RateInfo : [profile.RateInfo || {}];
const partsTaxRates = {};
/**
* In this context, r.RateType._ accesses the property named _ on the RateType object.
* This pattern is common when handling data parsed from XML, where element values are stored under the _ key. So,
* _ aligns to the actual value/content of the RateType field when RateType is an object (not a string).
*/
for (const r of rateInfos) {
const rateTypeRaw =
typeof r?.RateType === "string"

View File

@@ -0,0 +1,197 @@
{
"OP0": {
"desc": "REMOVE / REPLACE PARTIAL",
"opcode": "OP11",
"partcode": "PAA"
},
"OP1": {
"desc": "REFINISH / REPAIR",
"opcode": "OP1",
"partcode": "PAE"
},
"OP2": {
"desc": "REMOVE / INSTALL",
"opcode": "OP2",
"partcode": "PAE"
},
"OP3": {
"desc": "ADDITIONAL LABOR",
"opcode": "OP9",
"partcode": "PAE"
},
"OP4": {
"desc": "ALIGNMENT",
"opcode": "OP4",
"partcode": "PAS"
},
"OP5": {
"desc": "OVERHAUL",
"opcode": "OP5",
"partcode": "PAE"
},
"OP6": {
"desc": "REFINISH",
"opcode": "OP6",
"partcode": "PAE"
},
"OP7": {
"desc": "INSPECT",
"opcode": "OP7",
"partcode": "PAE"
},
"OP8": {
"desc": "CHECK / ADJUST",
"opcode": "OP8",
"partcode": "PAE"
},
"OP9": {
"desc": "REPAIR",
"opcode": "OP9",
"partcode": "PAE"
},
"OP10": {
"desc": "REPAIR , PARTIAL",
"opcode": "OP9",
"partcode": "PAE"
},
"OP11": {
"desc": "REMOVE / REPLACE",
"opcode": "OP11",
"partcode": "PAN"
},
"OP12": {
"desc": "REMOVE / REPLACE PARTIAL",
"opcode": "OP11",
"partcode": "PAN"
},
"OP13": {
"desc": "ADDITIONAL COSTS",
"opcode": "OP13",
"partcode": "PAE"
},
"OP14": {
"desc": "ADDITIONAL OPERATIONS",
"opcode": "OP14",
"partcode": "PAE"
},
"OP15": {
"desc": "BLEND",
"opcode": "OP15",
"partcode": "PAE"
},
"OP16": {
"desc": "SUBLET",
"opcode": "OP16",
"partcode": "PAS"
},
"OP17": {
"desc": "POLICY LIMIT ADJUSTMENT",
"opcode": "OP9",
"partcode": "PAE"
},
"OP18": {
"desc": "APPEAR ALLOWANCE",
"opcode": "OP7",
"partcode": "PAE"
},
"OP20": {
"desc": "REMOVE AND REINSTALL",
"opcode": "OP20",
"partcode": "PAE"
},
"OP24": {
"desc": "CHIPGUARD",
"opcode": "OP6",
"partcode": "PAE"
},
"OP25": {
"desc": "TWO TONE",
"opcode": "OP6",
"partcode": "PAE"
},
"OP26": {
"desc": "PAINTLESS DENT REPAIR",
"opcode": "OP16",
"partcode": "PAE"
},
"OP100": {
"desc": "REPLACE PRE-PRICED",
"opcode": "OP11",
"partcode": "PAA"
},
"OP101": {
"desc": "REMOVE/REPLACE RECYCLED PART",
"opcode": "OP11",
"partcode": "PAL"
},
"OP103": {
"desc": "REMOVE / REPLACE PARTIAL",
"opcode": "OP11",
"partcode": "PAA"
},
"OP104": {
"desc": "REMOVE / REPLACE PARTIAL LABOUR",
"opcode": "OP11",
"partcode": "PAA"
},
"OP105": {
"desc": "!!ADJUST MANUALLY!!",
"opcode": "OP99",
"partcode": "PAE"
},
"OP106": {
"desc": "REPAIR , PARTIAL",
"opcode": "OP9",
"partcode": "PAE"
},
"OP107": {
"desc": "CHIPGUARD",
"opcode": "OP6",
"partcode": "PAE"
},
"OP108": {
"desc": "MULTI TONE",
"opcode": "OP6",
"partcode": "PAE"
},
"OP109": {
"desc": "REPLACE PRE-PRICED",
"opcode": "OP11",
"partcode": "PAA"
},
"OP110": {
"desc": "REFINISH / REPAIR",
"opcode": "OP1",
"partcode": "PAE"
},
"OP111": {
"desc": "REMOVE / REPLACE",
"opcode": "OP11",
"partcode": "PAN"
},
"OP112": {
"desc": "REMOVE / REPLACE",
"opcode": "OP11",
"partcode": "PAA"
},
"OP113": {
"desc": "REPLACE PRE-PRICED",
"opcode": "OP11",
"partcode": "PAA"
},
"OP114": {
"desc": "REPLACE PRE-PRICED",
"opcode": "OP11",
"partcode": "PAA"
},
"OP120": {
"desc": "REPAIR , PARTIAL",
"opcode": "OP9",
"partcode": "PAE"
},
"OP260": {
"desc": "SUBLET",
"opcode": "OP16",
"partcode": "PAE"
}
}

View File

@@ -1,6 +1,5 @@
const admin = require("firebase-admin");
const client = require("../../../graphql-client/graphql-client").client;
const {
DELETE_SHOP,
DELETE_VENDORS_BY_SHOP,
@@ -18,143 +17,154 @@ const {
/**
* Deletes a Firebase user by UID.
* @param uid
* @param {string} uid - The Firebase user ID
* @returns {Promise<void>}
*/
const deleteFirebaseUser = async (uid) => {
if (!uid) throw new Error("User UID is required");
return admin.auth().deleteUser(uid);
};
/**
* Deletes all vendors associated with a shop.
* @param shopId
* @param {string} shopId - The shop ID
* @returns {Promise<void>}
*/
const deleteVendorsByShop = async (shopId) => {
if (!shopId) throw new Error("Shop ID is required");
await client.request(DELETE_VENDORS_BY_SHOP, { shopId });
};
/**
* Deletes a bodyshop from the database.
* @param shopId
* @param {string} shopId - The shop ID
* @returns {Promise<void>}
*/
const deleteBodyshop = async (shopId) => {
if (!shopId) throw new Error("Shop ID is required");
await client.request(DELETE_SHOP, { id: shopId });
};
/**
* Fetch job ids for a given shop
* @param shopId
* @param {string} shopId - The shop ID
* @returns {Promise<string[]>}
*/
const getJobIdsForShop = async (shopId) => {
if (!shopId) throw new Error("Shop ID is required");
const resp = await client.request(GET_JOBS_BY_SHOP, { shopId });
return resp.jobs.map((j) => j.id);
return resp.jobs?.map((j) => j.id) || [];
};
/**
* Delete joblines for the given job ids
* @param jobIds {string[]}
* @param {string[]} jobIds - Array of job IDs
* @returns {Promise<number>} affected rows
*/
const deleteJoblinesForJobs = async (jobIds) => {
if (!jobIds.length) return 0;
if (!jobIds?.length) return 0;
const resp = await client.request(DELETE_JOBLINES_BY_JOB_IDS, { jobIds });
return resp.delete_joblines.affected_rows;
return resp.delete_joblines?.affected_rows || 0;
};
/**
* Delete jobs for the given job ids
* @param jobIds {string[]}
* @param {string[]} jobIds - Array of job IDs
* @returns {Promise<number>} affected rows
*/
const deleteJobsByIds = async (jobIds) => {
if (!jobIds.length) return 0;
if (!jobIds?.length) return 0;
const resp = await client.request(DELETE_JOBS_BY_IDS, { jobIds });
return resp.delete_jobs.affected_rows;
return resp.delete_jobs?.affected_rows || 0;
};
/**
* Handles deprovisioning a shop for parts management.
* @param req
* @param res
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @returns {Promise<*>}
*/
const partsManagementDeprovisioning = async (req, res) => {
const { logger } = req;
const p = req.body;
const { shopId } = req.body;
if (process.env.NODE_ENV === "production") {
if (process.env.NODE_ENV === "production" || process.env.HOSTNAME?.endsWith("compute.internal")) {
return res.status(403).json({ error: "Deprovisioning not allowed in production environment." });
}
try {
if (!p.shopId) {
if (!shopId) {
throw { status: 400, message: "shopId is required." };
}
// Fetch bodyshop and check external_shop_id
const shopResp = await client.request(GET_BODYSHOP, { id: p.shopId });
const shopResp = await client.request(GET_BODYSHOP, { id: shopId });
const shop = shopResp.bodyshops_by_pk;
if (!shop) {
throw { status: 404, message: `Bodyshop with id ${p.shopId} not found.` };
throw { status: 404, message: `Bodyshop with id ${shopId} not found.` };
}
if (!shop.external_shop_id) {
throw { status: 400, message: "Cannot delete bodyshop without external_shop_id." };
}
logger.log("admin-delete-shop", "debug", null, null, {
shopId: p.shopId,
shopId,
shopname: shop.shopname,
ioadmin: true
});
// Get vendors
const vendorsResp = await client.request(GET_VENDORS, { shopId: p.shopId });
const deletedVendors = vendorsResp.vendors.map((v) => v.name);
const vendorsResp = await client.request(GET_VENDORS, { shopId });
const deletedVendors = vendorsResp.vendors?.map((v) => v.name) || [];
// Get associated users
const assocResp = await client.request(GET_ASSOCIATED_USERS, { shopId: p.shopId });
const associatedUsers = assocResp.associations.map((assoc) => ({
authId: assoc.user.authid,
email: assoc.user.email
}));
const assocResp = await client.request(GET_ASSOCIATED_USERS, { shopId });
const associatedUsers =
assocResp.associations?.map((assoc) => ({
authId: assoc.user?.authid,
email: assoc.user?.email
})) || [];
// Delete associations for the shop
const assocDeleteResp = await client.request(DELETE_ASSOCIATIONS_BY_SHOP, { shopId: p.shopId });
const associationsDeleted = assocDeleteResp.delete_associations.affected_rows;
const assocDeleteResp = await client.request(DELETE_ASSOCIATIONS_BY_SHOP, { shopId });
const associationsDeleted = assocDeleteResp.delete_associations?.affected_rows || 0;
// For each user, check if they have remaining associations; if not, delete user and Firebase account
// Delete users with no remaining associations
const deletedUsers = [];
for (const user of associatedUsers) {
const countResp = await client.request(GET_USER_ASSOCIATIONS_COUNT, { userEmail: user.email });
const assocCount = countResp.associations_aggregate.aggregate.count;
if (assocCount === 0) {
await client.request(DELETE_USER, { email: user.email });
await deleteFirebaseUser(user.authId);
deletedUsers.push(user.email);
if (!user.email || !user.authId) continue;
try {
const countResp = await client.request(GET_USER_ASSOCIATIONS_COUNT, { userEmail: user.email });
const assocCount = countResp.associations_aggregate?.aggregate?.count || 0;
if (assocCount === 0) {
await client.request(DELETE_USER, { email: user.email });
await deleteFirebaseUser(user.authId);
deletedUsers.push(user.email);
}
} catch (userError) {
logger.log("admin-delete-user-error", "warn", null, null, {
email: user.email,
error: userError.message || userError
});
}
}
// Get all job ids for this shop, then delete joblines and jobs (joblines first)
const jobIds = await getJobIdsForShop(p.shopId);
// Delete jobs and joblines
const jobIds = await getJobIdsForShop(shopId);
const joblinesDeleted = await deleteJoblinesForJobs(jobIds);
const jobsDeleted = await deleteJobsByIds(jobIds);
// Delete any audit trail entries tied to this bodyshop to avoid FK violations
const auditResp = await client.request(DELETE_AUDIT_TRAIL_BY_SHOP, { shopId: p.shopId });
const auditDeleted = auditResp.delete_audit_trail.affected_rows;
// Delete audit trail
const auditResp = await client.request(DELETE_AUDIT_TRAIL_BY_SHOP, { shopId });
const auditDeleted = auditResp.delete_audit_trail?.affected_rows || 0;
// Delete vendors
await deleteVendorsByShop(p.shopId);
// Delete shop
await deleteBodyshop(p.shopId);
// Delete vendors and shop
await deleteVendorsByShop(shopId);
await deleteBodyshop(shopId);
// Summary log
logger.log("admin-delete-shop-summary", "info", null, null, {
shopId: p.shopId,
shopId,
shopname: shop.shopname,
associationsDeleted,
deletedUsers,
@@ -165,11 +175,11 @@ const partsManagementDeprovisioning = async (req, res) => {
});
return res.status(200).json({
message: `Bodyshop ${p.shopId} and associated resources deleted successfully.`,
deletedShop: { id: p.shopId, name: shop.shopname },
message: `Bodyshop ${shopId} and associated resources deleted successfully.`,
deletedShop: { id: shopId, name: shop.shopname },
deletedAssociationsCount: associationsDeleted,
deletedUsers: deletedUsers,
deletedVendors: deletedVendors,
deletedUsers,
deletedVendors,
deletedJoblinesCount: joblinesDeleted,
deletedJobsCount: jobsDeleted,
deletedAuditTrailCount: auditDeleted
@@ -177,9 +187,8 @@ const partsManagementDeprovisioning = async (req, res) => {
} catch (err) {
logger.log("admin-delete-shop-error", "error", null, null, {
message: err.message,
detail: err.detail || err
detail: err.detail || err.stack || err
});
return res.status(err.status || 500).json({ error: err.message || "Internal server error" });
}
};

View File

@@ -123,7 +123,7 @@ const insertUserAssociation = async (uid, email, shopId) => {
authid: uid,
validemail: true,
associations: {
data: [{ shopid: shopId, authlevel: 80, active: true }]
data: [{ shopid: shopId, authlevel: 99, active: true }]
}
}
};
@@ -139,11 +139,11 @@ const insertUserAssociation = async (uid, email, shopId) => {
*/
const partsManagementProvisioning = async (req, res) => {
const { logger } = req;
const p = { ...req.body, userEmail: req.body.userEmail?.toLowerCase() };
const body = { ...req.body, userEmail: req.body.userEmail?.toLowerCase() };
try {
await ensureEmailNotRegistered(p.userEmail);
requireFields(p, [
await ensureEmailNotRegistered(body.userEmail);
requireFields(body, [
"external_shop_id",
"shopname",
"address1",
@@ -155,33 +155,33 @@ const partsManagementProvisioning = async (req, res) => {
"phone",
"userEmail"
]);
await ensureExternalIdUnique(p.external_shop_id);
await ensureExternalIdUnique(body.external_shop_id);
logger.log("admin-create-shop-user", "debug", p.userEmail, null, {
logger.log("admin-create-shop-user", "debug", body.userEmail, null, {
request: req.body,
ioadmin: true
});
const shopInput = {
shopname: p.shopname,
address1: p.address1,
address2: p.address2 || null,
city: p.city,
state: p.state,
zip_post: p.zip_post,
country: p.country,
email: p.email,
external_shop_id: p.external_shop_id,
timezone: p.timezone || DefaultNewShop.timezone,
phone: p.phone,
shopname: body.shopname,
address1: body.address1,
address2: body.address2 || null,
city: body.city,
state: body.state,
zip_post: body.zip_post,
country: body.country,
email: body.email,
external_shop_id: body.external_shop_id,
timezone: body.timezone || DefaultNewShop.timezone,
phone: body.phone,
logo_img_path: {
src: p.logoUrl,
src: body.logoUrl,
width: "",
height: "",
headerMargin: DefaultNewShop.logo_img_path.headerMargin
},
features: {
allAccess: true, // TODO: should be false?
allAccess: false,
partsManagementOnly: true
},
md_ro_statuses: DefaultNewShop.md_ro_statuses,
@@ -191,16 +191,16 @@ const partsManagementProvisioning = async (req, res) => {
md_messaging_presets: DefaultNewShop.md_messaging_presets,
md_rbac: DefaultNewShop.md_rbac,
md_classes: DefaultNewShop.md_classes,
md_ins_cos: DefaultNewShop.md_ins_cos, // TODO need?
md_categories: DefaultNewShop.md_categories, // TODO need?
md_labor_rates: DefaultNewShop.md_labor_rates, // TODO need?
md_payment_types: DefaultNewShop.md_payment_types, // TODO need?
md_hour_split: DefaultNewShop.md_hour_split, // TODO need?
md_ccc_rates: DefaultNewShop.md_ccc_rates, // TODO need?
appt_alt_transport: DefaultNewShop.appt_alt_transport, // TODO need?
md_jobline_presets: DefaultNewShop.md_jobline_presets, // TODO need?
md_ins_cos: DefaultNewShop.md_ins_cos,
md_categories: DefaultNewShop.md_categories,
md_labor_rates: DefaultNewShop.md_labor_rates,
md_payment_types: DefaultNewShop.md_payment_types,
md_hour_split: DefaultNewShop.md_hour_split,
md_ccc_rates: DefaultNewShop.md_ccc_rates,
appt_alt_transport: DefaultNewShop.appt_alt_transport,
md_jobline_presets: DefaultNewShop.md_jobline_presets,
vendors: {
data: p.vendors.map((v) => ({
data: body.vendors.map((v) => ({
name: v.name,
street1: v.street1 || null,
street2: v.street2 || null,
@@ -221,14 +221,14 @@ const partsManagementProvisioning = async (req, res) => {
};
const newShopId = await insertBodyshop(shopInput);
const userRecord = await createFirebaseUser(p.userEmail, p.userPassword);
const userRecord = await createFirebaseUser(body.userEmail, body.userPassword);
let resetLink = null;
if (!p.userPassword) resetLink = await generateResetLink(p.userEmail);
if (!body.userPassword) resetLink = await generateResetLink(body.userEmail);
const createdUser = await insertUserAssociation(userRecord.uid, p.userEmail, newShopId);
const createdUser = await insertUserAssociation(userRecord.uid, body.userEmail, newShopId);
return res.status(200).json({
shop: { id: newShopId, shopname: p.shopname },
shop: { id: newShopId, shopname: body.shopname },
user: {
id: createdUser.id,
email: createdUser.email,
@@ -236,7 +236,7 @@ const partsManagementProvisioning = async (req, res) => {
}
});
} catch (err) {
logger.log("admin-create-shop-user-error", "error", p.userEmail, null, {
logger.log("admin-create-shop-user-error", "error", body.userEmail, null, {
message: err.message,
detail: err.detail || err
});

View File

@@ -1,9 +1,10 @@
// no-dd-sa:javascript-code-style/assignment-name
// CamelCase is used for GraphQL and database fields.
const client = require("../../../graphql-client/graphql-client").client;
const { extractPartsTaxRates } = require("./lib/extractPartsTaxRates");
const { parseXml, normalizeXmlObject } = require("../partsManagementUtils");
const opCodes = require("./lib/opCodes.json");
// New imports for S3 XML archival
const { uploadFileToS3 } = require("../../../utils/s3");
const InstanceMgr = require("../../../utils/instanceMgr").default;
// GraphQL Queries and Mutations
const {
@@ -12,9 +13,27 @@ const {
INSERT_OWNER,
INSERT_JOB_WITH_LINES
} = require("../partsManagement.queries");
const { v4: uuidv4 } = require("uuid");
// Defaults
const FALLBACK_DEFAULT_ORDER_STATUS = "OPEN";
const FALLBACK_DEFAULT_JOB_STATUS = "Open";
const ESTIMATE_XML_BUCKET =
process.env?.NODE_ENV === "development"
? "parts-estimates" // local/dev shared bucket name
: InstanceMgr({
imex: `imex-webest-xml`,
rome: `rome-webest-xml`
});
const buildEstimateXmlKey = (rq) => {
const refClaimNum = rq.RefClaimNum;
const shopId = rq.ShopID;
const ts = new Date().toISOString().replace(/:/g, "-");
const safeClaim = (refClaimNum || "no-claim").toString().replace(/[^A-Za-z0-9_-]/g, "_");
return `addRequest/${shopId}/${safeClaim}/${ts}-${uuidv4()}.xml`;
};
/**
* Fetches the default order status for a bodyshop.
@@ -22,13 +41,13 @@ const FALLBACK_DEFAULT_ORDER_STATUS = "OPEN";
* @param {object} logger - The logger instance.
* @returns {Promise<string>} The default status or fallback.
*/
const getDefaultOrderStatus = async (shopId, logger) => {
const getDefaultJobStatus = async (shopId, logger) => {
try {
const { bodyshop_by_pk } = await client.request(GET_BODYSHOP_STATUS, { id: shopId });
return bodyshop_by_pk?.md_order_statuses?.default_open || FALLBACK_DEFAULT_ORDER_STATUS;
return bodyshop_by_pk?.md_ro_statuses?.default_imported || FALLBACK_DEFAULT_JOB_STATUS;
} catch (err) {
logger.log("parts-bodyshop-fetch-failed", "warn", shopId, null, { error: err });
return FALLBACK_DEFAULT_ORDER_STATUS;
return FALLBACK_DEFAULT_JOB_STATUS;
}
};
/**
@@ -65,7 +84,9 @@ const extractJobData = (rq) => {
const ci = rq.ClaimInfo || {};
return {
driveable: !!rq.VehicleInfo?.Condition?.DrivableInd,
shopId: rq.ShopID || rq.shopId,
// status: ci.ClaimStatus || null, Proper, setting it default for now
refClaimNum: rq.RefClaimNum,
ciecaid: rq.RqUID || null,
// Pull Cieca_ttl from ClaimInfo per schema/sample
@@ -81,7 +102,6 @@ const extractJobData = (rq) => {
scheduled_in: ev.RepairEvent?.RequestedPickUpDateTime || null,
scheduled_completion: ev.RepairEvent?.TargetCompletionDateTime || null,
clm_no: ci.ClaimNum || null,
status: ci.ClaimStatus || null,
policy_no: ci.PolicyInfo?.PolicyInfo?.PolicyNum || ci.PolicyInfo?.PolicyNum || null,
ded_amt: parseFloat(ci.PolicyInfo?.CoverageInfo?.Coverage?.DeductibleInfo?.DeductibleAmt || 0)
};
@@ -100,17 +120,18 @@ const extractOwnerData = (rq, shopId) => {
const personName = personInfo.PersonName || {};
const address = personInfo.Communications?.Address || {};
let ownr_ph1, ownr_ph2, ownr_ea, ownr_alt_ph;
let ownr_ph1, ownr_ph2, ownr_ea;
const comms = Array.isArray(ownerOrClaimant.ContactInfo?.Communications)
? ownerOrClaimant.ContactInfo.Communications
: [ownerOrClaimant.ContactInfo?.Communications || {}];
for (const c of comms) {
// -- Document
if (c.CommQualifier === "CP") ownr_ph1 = c.CommPhone;
if (c.CommQualifier === "WP") ownr_ph2 = c.CommPhone;
if (c.CommQualifier === "EM") ownr_ea = c.CommEmail;
if (c.CommQualifier === "AL") ownr_alt_ph = c.CommPhone;
// if (c.CommQualifier === "AL") ownr_alt_ph = c.CommPhone;
}
return {
@@ -126,8 +147,8 @@ const extractOwnerData = (rq, shopId) => {
ownr_ctry: address.Country || null,
ownr_ph1,
ownr_ph2,
ownr_ea,
ownr_alt_ph
ownr_ea
// ownr_alt_ph
// ownr_id_qualifier: ownerOrClaimant.IDInfo?.IDQualifierCode || null // New
// ownr_id_num: ownerOrClaimant.IDInfo?.IDNum || null, // New
// ownr_preferred_contact: ownerOrClaimant.PreferredContactMethod || null // New
@@ -158,37 +179,40 @@ const extractEstimatorData = (rq) => {
* @param {object} rq - The VehicleDamageEstimateAddRq object.
* @returns {object} Adjuster data.
*/
const extractAdjusterData = (rq) => {
const adjParty = rq.AdminInfo?.Adjuster?.Party || {};
const adjComms = Array.isArray(adjParty.ContactInfo?.Communications)
? adjParty.ContactInfo.Communications
: [adjParty.ContactInfo?.Communications || {}];
return {
agt_ct_fn: adjParty.PersonInfo?.PersonName?.FirstName || null,
agt_ct_ln: adjParty.PersonInfo?.PersonName?.LastName || null,
agt_ct_ph: adjComms.find((c) => c.CommQualifier === "CP")?.CommPhone || null,
agt_ea: adjComms.find((c) => c.CommQualifier === "EM")?.CommEmail || null
};
};
// const extractAdjusterData = (rq) => {
// const adjParty = rq.AdminInfo?.Adjuster?.Party || {};
// const adjComms = Array.isArray(adjParty.ContactInfo?.Communications)
// ? adjParty.ContactInfo.Communications
// : [adjParty.ContactInfo?.Communications || {}];
//
// return {
// //TODO (FUTURE): I dont think we display agt_ct_* fields in app. Have they typically been sending data here?
// agt_ct_fn: adjParty.PersonInfo?.PersonName?.FirstName || null,
// agt_ct_ln: adjParty.PersonInfo?.PersonName?.LastName || null,
// agt_ct_ph: adjComms.find((c) => c.CommQualifier === "CP")?.CommPhone || null,
// agt_ea: adjComms.find((c) => c.CommQualifier === "EM")?.CommEmail || null
// };
// };
/**
* Extracts repair facility data from the XML request.
* @param {object} rq - The VehicleDamageEstimateAddRq object.
* @returns {object} Repair facility data.
*/
const extractRepairFacilityData = (rq) => {
const rfParty = rq.AdminInfo?.RepairFacility?.Party || {};
const rfComms = Array.isArray(rfParty.ContactInfo?.Communications)
? rfParty.ContactInfo.Communications
: [rfParty.ContactInfo?.Communications || {}];
return {
servicing_dealer: rfParty.OrgInfo?.CompanyName || null,
servicing_dealer_contact:
rfComms.find((c) => c.CommQualifier === "WP" || c.CommQualifier === "FX")?.CommPhone || null
};
};
// const extractRepairFacilityData = (rq) => {
// const rfParty = rq.AdminInfo?.RepairFacility?.Party || {};
// const rfComms = Array.isArray(rfParty.ContactInfo?.Communications)
// ? rfParty.ContactInfo.Communications
// : [rfParty.ContactInfo?.Communications || {}];
//
// return {
// servicing_dealer: rfParty.OrgInfo?.CompanyName || null,
// // TODO (Future): The servicing dealer fields are a relic from synergy for a few folks
// // TODO (Future): I suspect RF data could be ignored since they are the RF.
// servicing_dealer_contact:
// rfComms.find((c) => c.CommQualifier === "WP" || c.CommQualifier === "FX")?.CommPhone || null
// };
// };
/**
* Extracts loss information from the XML request.
@@ -202,10 +226,12 @@ const extractLossInfo = (rq) => {
loss_date: loss.LossDateTime || null,
loss_type: custom.LossTypeCode || null,
loss_desc: custom.LossTypeDesc || null
// primary_poi: loss.PrimaryPOI?.POICode || null,
// secondary_poi: loss.SecondaryPOI?.POICode || null,
// area_of_impact: {
// impact_1: loss.PrimaryPOI?.POICode || null,
// imact_2 :loss.SecondaryPOI?.POICode || null,
// },
// tlosind: rq.ClaimInfo?.LossInfo?.TotalLossInd || null,
// damage_memo: loss.DamageMemo || null, //(maybe ins_memo)
// total_loss_ind: rq.ClaimInfo?.LossInfo?.TotalLossInd || null // New
};
};
@@ -287,7 +313,8 @@ const extractVehicleData = (rq, shopId) => {
v_color: exterior.Color?.ColorName || null,
v_bstyle: desc.BodyStyle || null,
v_engine: desc.EngineDesc || null,
v_options: desc.SubModelDesc || null,
// TODO (for future) Need to confirm with exact data, but this is typically a list of options. Not used AFAIK.
// v_options: desc.SubModelDesc || null,
v_type: desc.FuelType || null,
v_cond: rq.VehicleInfo?.Condition?.DrivableInd,
v_trimcode: desc.TrimCode || null,
@@ -336,27 +363,34 @@ const extractJobLines = (rq) => {
const lineOut = { ...base };
// Manual line flag coercion
if (line.ManualLineInd !== undefined) {
lineOut.manual_line =
line.ManualLineInd === true ||
line.ManualLineInd === 1 ||
line.ManualLineInd === "1" ||
(typeof line.ManualLineInd === "string" && line.ManualLineInd.toUpperCase() === "Y");
} else {
lineOut.manual_line = null;
}
// if (line.ManualLineInd !== undefined) {
// lineOut.manual_line =
// line.ManualLineInd === true ||
// line.ManualLineInd === 1 ||
// line.ManualLineInd === "1" ||
// // TODO (FUTURE): manual line tracks manual in IO or not, this woudl presumably always be false
// (typeof line.ManualLineInd === "string" && line.ManualLineInd.toUpperCase() === "Y");
// } else {
// lineOut.manual_line = null;
// }
// Is set to false because anything coming from the DMS is considered not a manual line, it becomes
// a manual line once it is edited in OUR system.
lineOut.manual_line = false;
// Parts (preferred) or Sublet (fallback when no PartInfo)
const hasPart = Object.keys(partInfo).length > 0;
const hasSublet = Object.keys(subletInfo).length > 0;
if (hasPart) {
const price = parseFloat(partInfo.PartPrice || partInfo.ListPrice || 0);
lineOut.part_type = partInfo.PartType || null ? String(partInfo.PartType).toUpperCase() : null;
lineOut.part_qty = parseFloat(partInfo.Quantity || 0) || 1;
lineOut.oem_partno = partInfo.OEMPartNum || partInfo.PartNum || null;
lineOut.db_price = isNaN(price) ? 0 : price;
lineOut.act_price = isNaN(price) ? 0 : price;
lineOut.oem_partno = partInfo.OEMPartNum;
lineOut.alt_partno = partInfo?.NonOEM?.NonOEMPartNum;
// THIS NEEDS TO BE CHANGED IN CHANGE REQUEST
lineOut.act_price = parseFloat(partInfo?.PartPrice || 0);
lineOut.db_price = parseFloat(partInfo?.OEMPartPrice || 0);
// Tax flag from PartInfo.TaxableInd when provided
if (
@@ -371,7 +405,12 @@ const extractJobLines = (rq) => {
partInfo.TaxableInd === "1" ||
(typeof partInfo.TaxableInd === "string" && partInfo.TaxableInd.toUpperCase() === "Y");
}
} else if (hasSublet) {
}
//TODO (FUTURE): Some nuance here. Usually a part and sublet amount shouldnt be on the same line, but they theoretically
// could. May require additional discussion.
// EMS - > Misc Amount, calibration for example, painting, etc
else if (hasSublet) {
const amt = parseFloat(subletInfo.SubletAmount || 0);
lineOut.part_type = "PAS"; // Sublet as parts-as-service
lineOut.part_qty = 1;
@@ -385,14 +424,22 @@ const extractJobLines = (rq) => {
(!!laborInfo.LaborType && String(laborInfo.LaborType).length > 0) ||
(!isNaN(hrs) && hrs !== 0) ||
(!isNaN(amt) && amt !== 0);
if (hasLabor) {
lineOut.mod_lbr_ty = laborInfo.LaborType || null;
lineOut.mod_lb_hrs = isNaN(hrs) ? 0 : hrs;
lineOut.lbr_op = laborInfo.LaborOperation || null;
const opCodeKey =
typeof laborInfo.LaborOperation === "string" ? laborInfo.LaborOperation.trim().toUpperCase() : null;
lineOut.op_code_desc = opCodes?.[opCodeKey]?.desc || null;
lineOut.lbr_amt = isNaN(amt) ? 0 : amt;
}
//TODO (FUTURE): what's the BMS logic for this? Body and refinish operations can often happen to the same part,
// but most systems output a second line for the refinish labor.
//TODO (FUTURE): 2nd line may include a duplicate of the part price, but that can be removed. This is the case for CCC.
// Refinish labor (if present) recorded on the same line using secondary labor fields
const rHrs = parseFloat(refinishInfo.LaborHours || 0);
const rAmt = parseFloat(refinishInfo.LaborAmt || 0);
const hasRefinish =
@@ -402,9 +449,9 @@ const extractJobLines = (rq) => {
!isNaN(rAmt) ||
!!refinishInfo.LaborOperation);
if (hasRefinish) {
lineOut.lbr_typ_j = refinishInfo.LaborType || "LAR";
lineOut.lbr_hrs_j = isNaN(rHrs) ? 0 : rHrs;
lineOut.lbr_op_j = refinishInfo.LaborOperation || null;
lineOut.lbr_typ_j = !!refinishInfo?.LaborAmtJudgmentInd;
lineOut.lbr_hrs_j = !!refinishInfo?.LaborHoursJudgmentInd;
lineOut.lbr_op_j = !!refinishInfo.LaborOperationJudgmentInd;
// Aggregate refinish labor amount into the total labor amount for the line
if (!isNaN(rAmt)) {
lineOut.lbr_amt = (Number.isFinite(lineOut.lbr_amt) ? lineOut.lbr_amt : 0) + rAmt;
@@ -420,26 +467,26 @@ const extractJobLines = (rq) => {
};
// Helper to extract a GRAND TOTAL amount from RepairTotalsInfo
const extractGrandTotal = (rq) => {
const rti = rq.RepairTotalsInfo;
const groups = Array.isArray(rti) ? rti : rti ? [rti] : [];
for (const grp of groups) {
const sums = Array.isArray(grp.SummaryTotalsInfo)
? grp.SummaryTotalsInfo
: grp.SummaryTotalsInfo
? [grp.SummaryTotalsInfo]
: [];
for (const s of sums) {
const type = (s.TotalType || "").toString().toUpperCase();
const desc = (s.TotalTypeDesc || "").toString().toUpperCase();
if (type.includes("GRAND") || type === "TOTAL" || desc.includes("GRAND")) {
const amt = parseFloat(s.TotalAmt ?? "NaN");
if (!isNaN(amt)) return amt;
}
}
}
return null;
};
// const extractGrandTotal = (rq) => {
// const rti = rq.RepairTotalsInfo;
// const groups = Array.isArray(rti) ? rti : rti ? [rti] : [];
// for (const grp of groups) {
// const sums = Array.isArray(grp.SummaryTotalsInfo)
// ? grp.SummaryTotalsInfo
// : grp.SummaryTotalsInfo
// ? [grp.SummaryTotalsInfo]
// : [];
// for (const s of sums) {
// const type = (s.TotalType || "").toString().toUpperCase();
// const desc = (s.TotalTypeDesc || "").toString().toUpperCase();
// if (type.includes("GRAND") || type === "TOTAL" || desc.includes("GRAND")) {
// const amt = parseFloat(s.TotalAmt ?? "NaN");
// if (!isNaN(amt)) return amt;
// }
// }
// }
// return null;
// };
/**
* Inserts an owner and returns the owner ID.
@@ -458,24 +505,27 @@ const insertOwner = async (ownerInput, logger) => {
};
// Fallback: compute a naive total from joblines (parts + sublet + labor amounts)
const computeLinesTotal = (joblines = []) => {
let parts = 0;
let labor = 0;
for (const jl of joblines) {
if (jl?.part_type) {
const qty = Number.isFinite(jl.part_qty) ? jl.part_qty : 1;
const price = Number.isFinite(jl.act_price) ? jl.act_price : 0;
parts += price * (qty || 1);
} else if (!jl.part_type && Number.isFinite(jl.act_price)) {
parts += jl.act_price;
}
if (Number.isFinite(jl.lbr_amt)) {
labor += jl.lbr_amt;
}
}
const total = parts + labor;
return Number.isFinite(total) && total > 0 ? total : 0;
};
// const computeLinesTotal = (joblines = []) => {
// let parts = 0;
// let labor = 0;
// for (const jl of joblines) {
// if (jl?.part_type) {
// const qty = Number.isFinite(jl.part_qty) ? jl.part_qty : 1;
// const price = Number.isFinite(jl.act_price) ? jl.act_price : 0;
// parts += price * (qty || 1);
// } else if (!jl.part_type && Number.isFinite(jl.act_price)) {
// parts += jl.act_price;
// }
// if (Number.isFinite(jl.lbr_amt)) {
// labor += jl.lbr_amt;
// }
// }
// const total = parts + labor;
//
// //TODO (FUTURE): clm_total is the 100% full amount of the repair including deductible, betterment and taxes. Typically provided by the source system.
// return Number.isFinite(total) && total > 0 ? total : 0;
// //TODO (FUTURE): clm_total is the 100% full amount of the repair including deductible,
// // betterment and taxes. Typically provided by the source system.
/**
* Handles the VehicleDamageEstimateAddRq XML request from parts management.
@@ -485,17 +535,10 @@ const computeLinesTotal = (joblines = []) => {
*/
const vehicleDamageEstimateAddRq = async (req, res) => {
const { logger } = req;
const rawXml = typeof req.body === "string" ? req.body : Buffer.isBuffer(req.body) ? req.body.toString("utf8") : "";
try {
// Parse XML
const payload = await parseXml(req.body, logger);
const rq = normalizeXmlObject(payload.VehicleDamageEstimateAddRq);
if (!rq) {
logger.log("parts-missing-root", "error");
return res.status(400).send("Missing <VehicleDamageEstimateAddRq>");
}
// Extract job data
const {
shopId,
refClaimNum,
@@ -512,40 +555,23 @@ const vehicleDamageEstimateAddRq = async (req, res) => {
scheduled_in,
scheduled_completion,
clm_no,
status,
policy_no,
ded_amt
ded_amt,
driveable
} = extractJobData(rq);
if (!shopId) {
throw { status: 400, message: "Missing <ShopID> in XML" };
}
// Get default status
const defaultStatus = await getDefaultOrderStatus(shopId, logger);
// Extract additional data
const defaultStatus = await getDefaultJobStatus(shopId, logger);
const parts_tax_rates = extractPartsTaxRates(rq.ProfileInfo);
const ownerData = extractOwnerData(rq, shopId);
const estimatorData = extractEstimatorData(rq);
const adjusterData = extractAdjusterData(rq);
const repairFacilityData = extractRepairFacilityData(rq);
const vehicleData = extractVehicleData(rq, shopId);
const lossInfo = extractLossInfo(rq);
const joblinesData = extractJobLines(rq);
const insuranceData = extractInsuranceData(rq);
// Derive clm_total: prefer RepairTotalsInfo SummaryTotals GRAND TOTAL; else sum from lines
const grandTotal = extractGrandTotal(rq);
const computedTotal = grandTotal ?? computeLinesTotal(joblinesData);
// Find or create relationships
const ownerid = await insertOwner(ownerData, logger);
const vehicleid = await findExistingVehicle(shopId, vehicleData.v_vin, logger);
// Build job input
const jobInput = {
shopid: shopId,
driveable,
converted: true,
ownerid,
ro_number: refClaimNum,
@@ -556,8 +582,8 @@ const vehicleDamageEstimateAddRq = async (req, res) => {
class: classType,
parts_tax_rates,
clm_no,
status: status || defaultStatus,
clm_total: computedTotal || null,
status: defaultStatus,
clm_total: 0,
policy_no,
ded_amt,
comment,
@@ -567,14 +593,10 @@ const vehicleDamageEstimateAddRq = async (req, res) => {
asgn_date,
scheduled_in,
scheduled_completion,
// Inline insurance/loss/contacts
...insuranceData,
...lossInfo,
...ownerData,
...estimatorData,
...adjusterData,
...repairFacilityData,
// Inline vehicle data
v_vin: vehicleData.v_vin,
v_model_yr: vehicleData.v_model_yr,
v_model_desc: vehicleData.v_model_desc,
@@ -585,10 +607,23 @@ const vehicleDamageEstimateAddRq = async (req, res) => {
...(vehicleid ? { vehicleid } : { vehicle: { data: vehicleData } }),
joblines: { data: joblinesData }
};
// Insert job
const { insert_jobs_one: newJob } = await client.request(INSERT_JOB_WITH_LINES, { job: jobInput });
// Upload AFTER job creation to include job id in filename
(async () => {
try {
const key = buildEstimateXmlKey(rq);
await uploadFileToS3({
bucketName: ESTIMATE_XML_BUCKET,
key,
content: rawXml || "",
contentType: "application/xml"
});
logger.log("parts-estimate-xml-uploaded", "info", shopId, newJob.id, { key, bytes: rawXml?.length || 0 });
} catch (e) {
logger.log("parts-estimate-xml-upload-failed", "warn", shopId, null, { error: e?.message });
}
})();
return res.status(200).json({ success: true, jobId: newJob.id });
} catch (err) {
logger.log("parts-route-error", "error", null, null, { error: err });

View File

@@ -4,24 +4,30 @@
const client = require("../../../graphql-client/graphql-client").client;
const { parseXml, normalizeXmlObject } = require("../partsManagementUtils");
const { extractPartsTaxRates } = require("./lib/extractPartsTaxRates");
const opCodes = require("./lib/opCodes.json");
const { uploadFileToS3 } = require("../../../utils/s3");
const InstanceMgr = require("../../../utils/instanceMgr").default;
const {
GET_JOB_BY_CLAIM,
GET_JOB_BY_ID,
UPDATE_JOB_BY_ID,
SOFT_DELETE_JOBLINES_BY_IDS,
GET_JOBLINES_NOTES_BY_JOBID_UNQSEQ,
GET_JOBLINE_IDS_BY_JOBID_UNQSEQ,
UPDATE_JOBLINE_BY_PK,
INSERT_JOBLINES
} = require("../partsManagement.queries");
/**
* Finds a job by shop ID and claim number.
* Finds a job by shop ID and job ID.
* @param shopId
* @param claimNum
* @param jobId
* @param logger
* @returns {Promise<*|null>}
*/
const findJob = async (shopId, claimNum, logger) => {
const findJob = async (shopId, jobId, logger) => {
try {
const { jobs } = await client.request(GET_JOB_BY_CLAIM, { shopid: shopId, clm_no: claimNum });
const { jobs } = await client.request(GET_JOB_BY_ID, { shopid: shopId, jobid: jobId });
return jobs?.[0] || null;
} catch (err) {
logger.log("parts-job-lookup-failed", "error", null, null, { error: err });
@@ -31,37 +37,39 @@ const findJob = async (shopId, claimNum, logger) => {
/**
* Extracts updated job data from the request payload.
* Mirrors AddRq for parts_tax_rates + driveable when present.
* @param rq
* @returns {{comment: (number|((comment: Comment, helper: postcss.Helpers) => (Promise<void> | void))|string|null), clm_no: null, status: (*|null), policy_no: (*|null)}}
*/
const extractUpdatedJobData = (rq) => {
const doc = rq.DocumentInfo || {};
const claim = rq.ClaimInfo || {};
const policyNo = claim.PolicyInfo?.PolicyInfo?.PolicyNum || claim.PolicyInfo?.PolicyNum || null;
const out = {
comment: doc.Comment || null,
clm_no: claim.ClaimNum || null,
status: claim.ClaimStatus || null,
// TODO (future): status omitted intentionally to avoid overwriting with 'Auth Cust'
policy_no: policyNo
};
// If ProfileInfo provided in ChangeRq, update parts_tax_rates to stay in sync with AddRq behavior
if (rq.ProfileInfo) {
out.parts_tax_rates = extractPartsTaxRates(rq.ProfileInfo);
}
if (rq.VehicleInfo?.Condition?.DrivableInd !== undefined) {
out.driveable = !!rq.VehicleInfo.Condition.DrivableInd;
}
return out;
};
/**
* Extracts updated job lines from the request payload without splitting parts and labor:
* - Keep part and labor on the same jobline
* - Aggregate RefinishLabor into secondary labor fields and add its amount to lbr_amt
* - SUBLET-only lines become PAS part_type with act_price = SubletAmount
* Build jobline payloads for updates/inserts (no split between parts & labor).
* - Refinish labor aggregated into lbr_* secondary fields and lbr_amt.
* - SUBLET-only -> PAS line with act_price = SubletAmount.
* - Notes merged with current DB value by unq_seq.
*/
const extractUpdatedJobLines = (addsChgs = {}, jobId) => {
const extractUpdatedJobLines = (addsChgs = {}, jobId, currentJobLineNotes = {}) => {
const linesIn = Array.isArray(addsChgs.DamageLineInfo) ? addsChgs.DamageLineInfo : [addsChgs.DamageLineInfo || {}];
const coerceManual = (val) =>
@@ -83,35 +91,39 @@ const extractUpdatedJobLines = (addsChgs = {}, jobId) => {
unq_seq: parseInt(line.UniqueSequenceNum || 0, 10),
status: line.LineStatusCode || null,
line_desc: line.LineDesc || null,
notes: line.LineMemo || null,
manual_line: line.ManualLineInd !== undefined ? coerceManual(line.ManualLineInd) : null
manual_line: false
// manual_line: line.ManualLineInd !== undefined ? coerceManual(line.ManualLineInd) : null
};
const lineOut = { ...base };
// --- Notes merge ---
const unqSeq = lineOut.unq_seq;
const currentNotes = currentJobLineNotes?.[unqSeq] || null;
const newNotes = line.LineMemo || null;
if (newNotes && currentNotes) {
if (currentNotes === newNotes || currentNotes.includes(newNotes)) lineOut.notes = currentNotes;
else lineOut.notes = `${currentNotes} | ${newNotes}`;
} else if (newNotes) lineOut.notes = newNotes;
else if (currentNotes) lineOut.notes = currentNotes;
else lineOut.notes = null;
// --- end notes merge ---
const hasPart = Object.keys(partInfo).length > 0;
const hasSublet = Object.keys(subletInfo).length > 0;
if (hasPart) {
const price = parseFloat(partInfo.PartPrice || partInfo.ListPrice || 0);
lineOut.part_type = partInfo.PartType ? String(partInfo.PartType).toUpperCase() : null;
lineOut.part_qty = parseFloat(partInfo.Quantity || 0) || 1;
lineOut.oem_partno = partInfo.OEMPartNum || partInfo.PartNum || null;
lineOut.db_price = isNaN(price) ? 0 : price;
lineOut.act_price = isNaN(price) ? 0 : price;
lineOut.oem_partno = partInfo.OEMPartNum;
lineOut.alt_partno = partInfo?.NonOEM?.NonOEMPartNum;
lineOut.part_type = partInfo.PartType || null ? String(partInfo.PartType).toUpperCase() : null;
// Optional: taxability flag for parts
if (
partInfo.TaxableInd !== undefined &&
(typeof partInfo.TaxableInd === "string" ||
typeof partInfo.TaxableInd === "number" ||
typeof partInfo.TaxableInd === "boolean")
) {
lineOut.tax_part =
partInfo.TaxableInd === true ||
partInfo.TaxableInd === 1 ||
partInfo.TaxableInd === "1" ||
(typeof partInfo.TaxableInd === "string" && partInfo.TaxableInd.toUpperCase() === "Y");
lineOut.act_price = parseFloat(partInfo?.PartPrice || 0);
lineOut.db_price = parseFloat(partInfo?.OEMPartPrice || 0);
if (partInfo.TaxableInd !== undefined) {
const t = partInfo.TaxableInd;
lineOut.tax_part = t === true || t === 1 || t === "1" || (typeof t === "string" && t.toUpperCase() === "Y");
}
} else if (hasSublet) {
const amt = parseFloat(subletInfo.SubletAmount || 0);
@@ -120,7 +132,7 @@ const extractUpdatedJobLines = (addsChgs = {}, jobId) => {
lineOut.act_price = isNaN(amt) ? 0 : amt;
}
// Primary labor on same line
// Primary labor
const hrs = parseFloat(laborInfo.LaborHours || 0);
const amt = parseFloat(laborInfo.LaborAmt || 0);
const hasLabor =
@@ -130,11 +142,15 @@ const extractUpdatedJobLines = (addsChgs = {}, jobId) => {
if (hasLabor) {
lineOut.mod_lbr_ty = laborInfo.LaborType || null;
lineOut.mod_lb_hrs = isNaN(hrs) ? 0 : hrs;
lineOut.lbr_op = laborInfo.LaborOperation || null;
const opCodeKey =
typeof laborInfo.LaborOperation === "string" ? laborInfo.LaborOperation.trim().toUpperCase() : null;
lineOut.op_code_desc = opCodeKey && opCodes?.[opCodeKey]?.desc ? opCodes[opCodeKey].desc : null;
lineOut.lbr_amt = isNaN(amt) ? 0 : amt;
}
// Refinish labor on same line using secondary fields; aggregate amount into lbr_amt
// Refinish (secondary fields, add amount)
const rHrs = parseFloat(refinishInfo.LaborHours || 0);
const rAmt = parseFloat(refinishInfo.LaborAmt || 0);
const hasRefinish =
@@ -147,9 +163,7 @@ const extractUpdatedJobLines = (addsChgs = {}, jobId) => {
lineOut.lbr_typ_j = refinishInfo.LaborType || "LAR";
lineOut.lbr_hrs_j = isNaN(rHrs) ? 0 : rHrs;
lineOut.lbr_op_j = refinishInfo.LaborOperation || null;
if (!isNaN(rAmt)) {
lineOut.lbr_amt = (Number.isFinite(lineOut.lbr_amt) ? lineOut.lbr_amt : 0) + rAmt;
}
if (!isNaN(rAmt)) lineOut.lbr_amt = (Number.isFinite(lineOut.lbr_amt) ? lineOut.lbr_amt : 0) + rAmt;
if (refinishInfo.PaintStagesNum !== undefined) lineOut.paint_stg = refinishInfo.PaintStagesNum;
if (refinishInfo.PaintTonesNum !== undefined) lineOut.paint_tone = refinishInfo.PaintTonesNum;
}
@@ -161,68 +175,186 @@ const extractUpdatedJobLines = (addsChgs = {}, jobId) => {
};
/**
* Extracts deletion IDs from the deletions object, also removing any derived labor/refinish lines
* by including offsets (base + 400000, base + 500000).
* Expand deletion IDs to include derived labor/refinish offsets.
*/
const extractDeletions = (deletions = {}) => {
const items = Array.isArray(deletions.DamageLineInfo) ? deletions.DamageLineInfo : [deletions.DamageLineInfo || {}];
const baseSeqs = items.map((line) => parseInt(line.UniqueSequenceNum, 10)).filter((id) => Number.isInteger(id));
const allSeqs = [];
for (const u of baseSeqs) {
allSeqs.push(u, u + 400000, u + 500000);
}
// De-dup
for (const u of baseSeqs) allSeqs.push(u, u + 400000, u + 500000);
return Array.from(new Set(allSeqs));
};
// S3 bucket + key builder (mirrors AddRq but with changeRequest prefix)
const ESTIMATE_XML_BUCKET =
process.env?.NODE_ENV === "development"
? "parts-estimates"
: InstanceMgr({
imex: `imex-webest-xml`,
rome: `rome-webest-xml`
});
const buildEstimateXmlKey = (rq) => {
const shopId = rq.ShopID;
const jobId = rq.JobID;
const ts = new Date().toISOString().replace(/:/g, "-");
return `changeRequest/${shopId}/${jobId}/${ts}.xml`;
};
/**
* Handles VehicleDamageEstimateChgRq requests.
* @param req
* @param res
* @returns {Promise<*>}
* Convert a full jobline object into a jobs_set_input for update_by_pk (omit immutable fields).
*/
const toJoblineSetInput = (jl) => {
const {
// immutable identity fields:
// jobid,
// unq_seq,
// everything else:
line_no,
status,
line_desc,
manual_line,
notes,
part_qty,
oem_partno,
alt_partno,
part_type,
act_price,
db_price,
tax_part,
mod_lbr_ty,
mod_lb_hrs,
op_code_desc,
lbr_amt,
lbr_typ_j,
lbr_hrs_j,
lbr_op_j,
paint_stg,
paint_tone
} = jl;
return {
line_no,
status,
line_desc,
manual_line,
notes,
part_qty,
oem_partno,
alt_partno,
part_type,
act_price,
db_price,
tax_part,
mod_lbr_ty,
mod_lb_hrs,
op_code_desc,
lbr_amt,
lbr_typ_j,
lbr_hrs_j,
lbr_op_j,
paint_stg,
paint_tone
};
};
/**
* Handles VehicleDamageEstimateChgRq requests:
* - Update core job fields
* - For lines: update by PK if existing; otherwise bulk insert
* - Soft-delete only explicit deletions (exclude any updated seqs)
*/
const partsManagementVehicleDamageEstimateChgRq = async (req, res) => {
const { logger } = req;
const rawXml = typeof req.body === "string" ? req.body : Buffer.isBuffer(req.body) ? req.body.toString("utf8") : "";
try {
const payload = await parseXml(req.body, logger);
const rq = normalizeXmlObject(payload.VehicleDamageEstimateChgRq);
if (!rq) return res.status(400).send("Missing <VehicleDamageEstimateChgRq>");
const jobId = rq.JobID;
const shopId = rq.ShopID;
const claimNum = rq.ClaimInfo?.ClaimNum;
if (!shopId || !claimNum) return res.status(400).send("Missing ShopID or ClaimNum");
// Fire-and-forget archival on valid request
(async () => {
try {
const key = buildEstimateXmlKey(rq);
await uploadFileToS3({
bucketName: ESTIMATE_XML_BUCKET,
key,
content: rawXml || "",
contentType: "application/xml"
});
logger.log("parts-estimate-xml-uploaded", "info", jobId, null, { key, bytes: rawXml?.length || 0 });
} catch (e) {
logger.log("parts-estimate-xml-upload-failed", "warn", jobId, null, { error: e?.message });
}
})();
const job = await findJob(shopId, claimNum, logger);
const job = await findJob(shopId, jobId, logger);
if (!job) return res.status(404).send("Job not found");
const updatedJobData = extractUpdatedJobData(rq);
const updatedLines = extractUpdatedJobLines(rq.AddsChgs, job.id);
const deletedLineIds = extractDeletions(rq.Deletions);
// --- Updated seqs from incoming changes ---
const linesIn = Array.isArray(rq.AddsChgs?.DamageLineInfo)
? rq.AddsChgs.DamageLineInfo
: [rq.AddsChgs?.DamageLineInfo || {}];
await client.request(UPDATE_JOB_BY_ID, { id: job.id, job: updatedJobData });
// Build a set of unq_seq that will be updated (replaced). We delete them first to avoid duplicates.
const updatedSeqs = Array.from(
new Set((updatedLines || []).map((l) => l?.unq_seq).filter((v) => Number.isInteger(v)))
new Set((linesIn || []).map((l) => parseInt(l?.UniqueSequenceNum || 0, 10)).filter((v) => Number.isInteger(v)))
);
if (deletedLineIds?.length || updatedSeqs?.length) {
const allToDelete = Array.from(new Set([...(deletedLineIds || []), ...(updatedSeqs || [])]));
if (allToDelete.length) {
await client.request(SOFT_DELETE_JOBLINES_BY_IDS, { jobid: job.id, unqSeqs: allToDelete });
// --- Fetch current notes for merge ---
let currentJobLineNotes = {};
if (updatedSeqs.length > 0) {
const resp = await client.request(GET_JOBLINES_NOTES_BY_JOBID_UNQSEQ, { jobid: job.id, unqSeqs: updatedSeqs });
if (resp?.joblines) {
for (const jl of resp.joblines) currentJobLineNotes[jl.unq_seq] = jl.notes;
}
}
if (updatedLines.length > 0) {
// Insert fresh versions after deletion so we dont depend on a unique constraint
await client.request(INSERT_JOBLINES, {
joblines: updatedLines
});
const updatedJobData = extractUpdatedJobData(rq);
const updatedLines = extractUpdatedJobLines(rq.AddsChgs, job.id, currentJobLineNotes);
// --- Look up existing rows (by natural key) to decide update vs insert ---
let existingIdByUnqSeq = {};
if (updatedSeqs.length > 0) {
const existing = await client.request(GET_JOBLINE_IDS_BY_JOBID_UNQSEQ, { jobid: job.id, unqSeqs: updatedSeqs });
if (existing?.joblines) {
for (const row of existing.joblines) existingIdByUnqSeq[row.unq_seq] = row.id;
}
}
const toUpdate = [];
const toInsert = [];
for (const jl of updatedLines) {
const id = existingIdByUnqSeq[jl.unq_seq];
if (id) toUpdate.push({ id, _set: toJoblineSetInput(jl) });
else toInsert.push(jl);
}
// Build deletions list and exclude any seqs we are updating (avoid accidental removal)
const deletedLineIdsAll = extractDeletions(rq.Deletions);
const deletionSeqs = deletedLineIdsAll.filter((u) => !updatedSeqs.includes(u));
// Mutations:
const updateJobPromise = client.request(UPDATE_JOB_BY_ID, { id: job.id, job: updatedJobData });
const softDeletePromise = deletionSeqs.length
? client.request(SOFT_DELETE_JOBLINES_BY_IDS, { jobid: job.id, unqSeqs: deletionSeqs })
: Promise.resolve({});
// Update each existing row by primary key (parallelized)
const perRowUpdatesPromise =
toUpdate.length > 0
? Promise.all(toUpdate.map(({ id, _set }) => client.request(UPDATE_JOBLINE_BY_PK, { id, jl: _set })))
: Promise.resolve([]);
// Insert brand-new rows in bulk
const insertPromise =
toInsert.length > 0 ? client.request(INSERT_JOBLINES, { joblines: toInsert }) : Promise.resolve({});
await Promise.all([updateJobPromise, softDeletePromise, perRowUpdatesPromise, insertPromise]);
logger.log("parts-job-changed", "info", job.id, null);
return res.status(200).json({ success: true, jobId: job.id });
} catch (err) {

View File

@@ -2,7 +2,7 @@
const GET_BODYSHOP_STATUS = `
query GetBodyshopStatus($id: uuid!) {
bodyshops_by_pk(id: $id) {
md_order_statuses
md_ro_statuses
}
}
`;
@@ -44,60 +44,22 @@ const GET_JOB_BY_CLAIM = `
}
`;
const UPDATE_JOB_BY_ID = `
mutation UpdateJobById($id: uuid!, $job: jobs_set_input!) {
update_jobs_by_pk(pk_columns: { id: $id }, _set: $job) {
const GET_JOB_BY_ID = `
query GetJobByID($shopid: uuid!, $jobid: uuid!) {
jobs(
where: { shopid: { _eq: $shopid }, id: { _eq: $jobid } }
order_by: { created_at: desc }
limit: 1
) {
id
}
}
`;
const UPSERT_JOBLINES = `
mutation UpsertJoblines($joblines: [joblines_insert_input!]!) {
insert_joblines(
objects: $joblines
on_conflict: {
constraint: joblines_pkey
update_columns: [
jobid
status
line_desc
part_type
part_qty
oem_partno
db_price
act_price
mod_lbr_ty
mod_lb_hrs
lbr_op
lbr_amt
notes
manual_line
]
}
) {
affected_rows
}
}
`;
const DELETE_JOBLINES_BY_JOBID = `
mutation DeleteJoblinesByJobId($jobid: uuid!) {
delete_joblines(where: { jobid: { _eq: $jobid } }) {
affected_rows
}
}
`;
const DELETE_JOBLINES_BY_IDS = `
mutation DeleteJoblinesByIds($jobid: uuid!, $unqSeqs: [Int!]!) {
delete_joblines(
where: {
jobid: { _eq: $jobid },
unq_seq: { _in: $unqSeqs }
}
) {
affected_rows
const UPDATE_JOB_BY_ID = `
mutation UpdateJobById($id: uuid!, $job: jobs_set_input!) {
update_jobs_by_pk(pk_columns: { id: $id }, _set: $job) {
id
}
}
`;
@@ -245,6 +207,97 @@ const DELETE_AUDIT_TRAIL_BY_SHOP = `
}
`;
const GET_JOBLINES_NOTES_BY_JOBID_UNQSEQ = `
query GetJoblinesNotesByJobIdUnqSeq($jobid: uuid!, $unqSeqs: [Int!]!) {
joblines(where: { jobid: { _eq: $jobid }, unq_seq: { _in: $unqSeqs }, removed: { _neq: true } }) {
unq_seq
notes
}
}
`;
// Clear task links to parts orders for all jobs in a shop to avoid FK violations when deleting parts orders
const CLEAR_TASKS_PARTSORDER_LINKS_BY_JOBIDS = `
mutation ClearTasksPartsOrderLinks($jobIds: [uuid!]!) {
update_tasks(
where: { parts_order: { jobid: { _in: $jobIds } } },
_set: { partsorderid: null }
) {
affected_rows
}
}
`;
// Delete parts order lines where the parent order belongs to any of the provided job IDs
const DELETE_PARTS_ORDER_LINES_BY_JOB_IDS = `
mutation DeletePartsOrderLinesByJobIds($jobIds: [uuid!]!) {
delete_parts_order_lines(where: { parts_order: { jobid: { _in: $jobIds } } }) {
affected_rows
}
}
`;
// Delete parts orders for the given job IDs
const DELETE_PARTS_ORDERS_BY_JOB_IDS = `
mutation DeletePartsOrdersByJobIds($jobIds: [uuid!]!) {
delete_parts_orders(where: { jobid: { _in: $jobIds } }) {
affected_rows
}
}
`;
const UPSERT_JOBLINES = `
mutation UpsertJoblines($joblines: [joblines_insert_input!]!) {
insert_joblines(
objects: $joblines,
on_conflict: {
constraint: joblines_jobid_unq_seq_key,
update_columns: [
status,
line_desc,
notes,
manual_line,
part_qty,
oem_partno,
alt_partno,
part_type,
act_price,
db_price,
tax_part,
mod_lbr_ty,
mod_lb_hrs,
op_code_desc,
lbr_amt,
lbr_typ_j,
lbr_hrs_j,
lbr_op_j,
paint_stg,
paint_tone
]
}
) {
affected_rows
}
}
`;
// Get jobline IDs for the incoming unq_seq values (only non-removed)
const GET_JOBLINE_IDS_BY_JOBID_UNQSEQ = `
query GetJoblineIdsByJobIdUnqSeq($jobid: uuid!, $unqSeqs: [Int!]!) {
joblines(where: { jobid: { _eq: $jobid }, unq_seq: { _in: $unqSeqs }, removed: { _neq: true } }) {
id
unq_seq
}
}
`;
// Update a single jobline by primary key
const UPDATE_JOBLINE_BY_PK = `
mutation UpdateJoblineByPk($id: uuid!, $jl: joblines_set_input!) {
update_joblines_by_pk(pk_columns: { id: $id }, _set: $jl) { id }
}
`;
module.exports = {
GET_BODYSHOP_STATUS,
GET_VEHICLE_BY_SHOP_VIN,
@@ -252,9 +305,6 @@ module.exports = {
INSERT_JOB_WITH_LINES,
GET_JOB_BY_CLAIM,
UPDATE_JOB_BY_ID,
UPSERT_JOBLINES,
DELETE_JOBLINES_BY_JOBID,
DELETE_JOBLINES_BY_IDS,
SOFT_DELETE_JOBLINES_BY_IDS,
INSERT_JOBLINES,
CHECK_EXTERNAL_SHOP_ID,
@@ -271,5 +321,13 @@ module.exports = {
GET_JOBS_BY_SHOP,
DELETE_JOBLINES_BY_JOB_IDS,
DELETE_JOBS_BY_IDS,
DELETE_AUDIT_TRAIL_BY_SHOP
DELETE_AUDIT_TRAIL_BY_SHOP,
GET_JOBLINES_NOTES_BY_JOBID_UNQSEQ,
GET_JOB_BY_ID,
CLEAR_TASKS_PARTSORDER_LINKS_BY_JOBIDS,
DELETE_PARTS_ORDER_LINES_BY_JOB_IDS,
DELETE_PARTS_ORDERS_BY_JOB_IDS,
UPSERT_JOBLINES,
GET_JOBLINE_IDS_BY_JOBID_UNQSEQ,
UPDATE_JOBLINE_BY_PK
};

View File

@@ -252,35 +252,27 @@ const generatePaymentUrl = async (req, res) => {
* @returns {Promise<void>}
*/
const checkFee = async (req, res) => {
const logResponseMeta = {
bodyshop: {
id: req.body?.bodyshop?.id,
imexshopid: req.body?.bodyshop?.imexshopid,
name: req.body?.bodyshop?.shopname,
state: req.body?.bodyshop?.state
},
amount: req.body?.amount
};
const { bodyshop = {}, amount } = req.body || {};
const { id, imexshopid, shopname, state } = bodyshop;
const logResponseMeta = { bodyshop: { id, imexshopid, name: shopname, state }, amount };
logger.log("intellipay-checkfee-request-received", "DEBUG", req.user?.email, null, logResponseMeta);
if (!isNumber(req.body?.amount) || req.body?.amount <= 0) {
if (!isNumber(amount) || amount <= 0) {
logger.log("intellipay-checkfee-skip", "DEBUG", req.user?.email, null, {
message: "Amount is zero or undefined, skipping fee check.",
...logResponseMeta
});
return res.json({ fee: 0 });
}
const shopCredentials = await getShopCredentials(req.body.bodyshop);
const shopCredentials = await getShopCredentials(bodyshop);
if (shopCredentials?.error) {
logger.log("intellipay-checkfee-credentials-error", "ERROR", req.user?.email, null, {
message: shopCredentials.error?.message,
...logResponseMeta
});
return res.status(400).json({ error: shopCredentials.error?.message, ...logResponseMeta });
}
@@ -292,13 +284,10 @@ const checkFee = async (req, res) => {
{
method: "fee",
...shopCredentials,
amount: req.body.amount,
paymenttype: `CC`,
amount: String(amount), // Type cast to string as required by API
paymenttype: "CC",
cardnum: "4111111111111111", // Required for compatibility with API
state:
req.body.bodyshop?.state && req.body.bodyshop.state.length === 2
? req.body.bodyshop.state.toUpperCase()
: "ZZ"
state: state?.toUpperCase() || "ZZ"
},
{ sort: false } // Ensure query string order is preserved
),
@@ -310,46 +299,24 @@ const checkFee = async (req, res) => {
...logResponseMeta
});
const response = await axios(options);
const { data } = await axios(options);
if (response.data?.error) {
logger.log("intellipay-checkfee-api-error", "ERROR", req.user?.email, null, {
message: response.data?.error,
...logResponseMeta
});
return res.status(400).json({
error: response.data?.error,
type: "intellipay-checkfee-api-error",
...logResponseMeta
});
if (data?.error || data < 0) {
const errorType = data?.error ? "intellipay-checkfee-api-error" : "intellipay-checkfee-negative-fee";
const errorMessage = data?.error
? data?.error
: "Fee amount negative. Check API credentials & account configuration.";
logger.log(errorType, "ERROR", req.user?.email, null, { message: errorMessage, data, ...logResponseMeta });
return res.status(400).json({ error: errorMessage, type: errorType, data, ...logResponseMeta });
}
if (response.data < 0) {
logger.log("intellipay-checkfee-negative-fee", "ERROR", req.user?.email, null, {
message: "Fee amount returned is negative.",
...logResponseMeta
});
return res.json({
error: "Fee amount negative. Check API credentials & account configuration.",
...logResponseMeta,
type: "intellipay-checkfee-negative-fee"
});
}
logger.log("intellipay-checkfee-success", "DEBUG", req.user?.email, null, {
fee: response.data,
...logResponseMeta
});
return res.json({ fee: response.data, ...logResponseMeta });
logger.log("intellipay-checkfee-success", "DEBUG", req.user?.email, null, { fee: data, ...logResponseMeta });
return res.json({ fee: data, ...logResponseMeta });
} catch (error) {
logger.log("intellipay-checkfee-error", "ERROR", req.user?.email, null, {
message: error?.message,
...logResponseMeta
});
return res.status(500).json({ error: error?.message, logResponseMeta });
}
};

View File

@@ -73,37 +73,23 @@ const processCanvasRequest = async (req, res) => {
// Default width and height
const width = isNumber(w) && w > 0 ? w : 500;
const height = isNumber(h) && h > 0 ? h : 275;
const configuration = getChartConfiguration(keys, values, override);
let canvas = null;
let ctx = null;
let chart = null;
let chartImage = null;
try {
// Create the canvas
canvas = new Canvas(width, height);
ctx = canvas.getContext("2d");
const canvas = new Canvas(width, height);
const ctx = canvas.getContext("2d");
// Render the chart
chart = new Chart(ctx, configuration);
// Generate and send the image
chartImage = (await canvas.toBuffer("image/png")).toString("base64");
const chartImage = (await canvas.toBuffer("image/png")).toString("base64");
res.status(200).send(`data:image/png;base64,${chartImage}`);
} catch (error) {
// Log the error and send the response
logger.log("canvas-error", "error", "jsr", null, { error: error.message });
res.status(500).send("Failed to generate canvas.");
res.status(500).send("Error generating canvas");
} finally {
// Cleanup resources
if (chart) {
chart.destroy();
}
ctx = null;
canvas = null;
chartImage = null;
chart?.destroy();
}
};
@@ -118,15 +104,18 @@ const enqueueRequest = (req, res) => {
};
const processNextInQueue = async () => {
while (requestQueue.length > 0) {
const { req, res } = requestQueue.shift();
try {
await processCanvasRequest(req, res);
} catch (err) {
console.error("canvas-queue-error", "error", "jsr", null, { error: err.message });
try {
while (requestQueue.length > 0) {
const { req, res } = requestQueue.shift();
try {
await processCanvasRequest(req, res);
} catch (err) {
console.error("canvas-queue-error", "error", "jsr", null, { error: err.message });
}
}
} finally {
isProcessing = false;
}
isProcessing = false;
};
exports.canvastest = function (req, res) {
@@ -134,7 +123,10 @@ exports.canvastest = function (req, res) {
};
exports.canvas = async (req, res) => {
if (isProcessing || !enqueueRequest(req, res)) return;
isProcessing = true;
processNextInQueue().catch((err) => console.error("canvas-processing-error", { error: err.message }));
if (!enqueueRequest(req, res)) return;
if (!isProcessing) {
isProcessing = true;
processNextInQueue().catch((err) => console.error("canvas-processing-error", { error: err.message }));
}
};

View File

@@ -1,5 +1,6 @@
const express = require("express");
const router = express.Router();
const logger = require("../../server/utils/logger");
// Pull secrets from env
const { VSSTA_INTEGRATION_SECRET, PARTS_MANAGEMENT_INTEGRATION_SECRET } = process.env;
@@ -11,7 +12,7 @@ if (typeof VSSTA_INTEGRATION_SECRET === "string" && VSSTA_INTEGRATION_SECRET.len
router.post("/vssta", vsstaMiddleware, vsstaIntegration);
} else {
console.warn("VSSTA_INTEGRATION_SECRET is not set — skipping /vssta integration route");
logger.logger.warn("VSSTA_INTEGRATION_SECRET is not set — skipping /vssta integration route");
}
// Only load Parts Management routes if that secret is set
@@ -38,21 +39,24 @@ if (typeof PARTS_MANAGEMENT_INTEGRATION_SECRET === "string" && PARTS_MANAGEMENT_
* Route to handle Vehicle Damage Estimate Change Request
*/
router.post(
"/parts-management/VehicleDamageEstimateChqRq",
"/parts-management/VehicleDamageEstimateChgRq",
express.raw({ type: "application/xml", limit: XML_BODY_LIMIT }), // Parse XML body
partsManagementIntegrationMiddleware,
partsManagementVehicleDamageEstimateChqRq
);
// Deprovisioning route
router.post("/parts-management/deprovision", partsManagementIntegrationMiddleware, partsManagementDeprovisioning);
if (process.env.NODE_ENV !== "production" && !process.env.HOSTNAME?.endsWith("compute.internal")) {
logger.logger.warn("Parts Management Deprovisioning route has been loaded.");
router.post("/parts-management/deprovision", partsManagementIntegrationMiddleware, partsManagementDeprovisioning);
}
/**
* Route to handle Parts Management Provisioning
*/
router.post("/parts-management/provision", partsManagementIntegrationMiddleware, partsManagementProvisioning);
} else {
console.warn("PARTS_MANAGEMENT_INTEGRATION_SECRET is not set — skipping /parts-management/provision route");
logger.logger.warn("PARTS_MANAGEMENT_INTEGRATION_SECRET is not set — skipping /parts-management/provision route");
}
module.exports = router;

View File

@@ -144,6 +144,9 @@ router.post("/emsupload", validateFirebaseIdTokenMiddleware, data.emsUpload);
// Redis Cache Routes
router.post("/bodyshop-cache", eventAuthorizationMiddleware, updateBodyshopCache);
// Estimate Scrubber Vehicle Type
router.post("/es/vehicletype", data.vehicletype);
// Health Check for docker-compose-cluster load balancer, only available in development
if (process.env.NODE_ENV === "development") {
router.get("/health", (req, res) => {