Compare commits

..

110 Commits

Author SHA1 Message Date
Allan Carr
f0c0b5dc45 IO-3576 Fortellis Refetch Make Model
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-23 15:28:07 -08:00
Allan Carr
a6a621e73f Merged in hotfix/2026-02-19 (pull request #3026)
IO-3570 Strip - from Owner Name in regex
2026-02-19 21:21:52 +00:00
Allan Carr
ee0f2c3293 Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3024)
IO-3570 Strip - from Owner Name in regex
2026-02-19 21:16:13 +00:00
Allan Carr
83a30f1fcd IO-3570 Strip - from Owner Name in regex
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-19 13:17:15 -08:00
Allan Carr
ba3e831503 Merged in hotfix/2026-02-19 (pull request #3023)
IO-3570 Fortellis Owner Phone Search
2026-02-19 20:37:09 +00:00
Allan Carr
6b87b15e97 IO-3570 Fortellis Owner Phone Search
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-19 12:36:43 -08:00
Allan Carr
425cdac26e Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3022)
IO-3570 Fortellis Owner Phone Search
2026-02-19 20:36:15 +00:00
Allan Carr
ade8461851 Merged in hotfix/2026-02-19 (pull request #3020)
Hotfix/2026 02 19
2026-02-19 20:07:21 +00:00
Allan Carr
f6c5f85a87 IO-3570 Transwip fix
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-19 12:04:44 -08:00
Allan Carr
532fa3fb18 Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3018)
Feature/IO-3570 Fortellis Multiple Veh Records
2026-02-19 20:02:45 +00:00
Allan Carr
c7875c7be3 IO-3570 Fix Regex to include numbers
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-19 11:59:36 -08:00
Allan Carr
78b9b8d260 Merged in hotfix/2026-02-19 (pull request #3016)
Hotfix/2026 02 19
2026-02-19 18:52:41 +00:00
Allan Carr
38fc3285b4 IO-3570 Check if array and then filter
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-19 10:52:37 -08:00
Allan Carr
9d14ad3167 Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3015)
Feature/IO-3570 Fortellis Multiple Veh Records
2026-02-19 18:50:28 +00:00
Allan Carr
2e53fe8606 IO-3570 Fortellis Multi Veh
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-19 10:46:50 -08:00
Allan Carr
6317606ce1 Merged in hotfix/2026-02-19 (pull request #3014)
IO-3570 Filter Vehicle Results from VIN Query to records that only have a vehicleVehID
2026-02-19 18:02:40 +00:00
Allan Carr
e599c2b2d6 Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3013)
IO-3570 Filter Vehicle Results from VIN Query to records that only have a vehicleVehID
2026-02-19 17:58:30 +00:00
Allan Carr
2b35090359 IO-3570 Filter Vehicle Results from VIN Query to records that only have a vehicleVehID
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-19 09:55:37 -08:00
Dave Richer
fa2c729ac2 Merged in release/2026-02-27 (pull request #3008)
feature/IO-3523-Fortellis-Corrections-2 - Fix

Approved-by: Allan Carr
2026-02-18 00:45:08 +00:00
Dave Richer
95bb5b03c2 Merged in feature/IO-3523-Fortellis-Corrections-2 (pull request #3006)
feature/IO-3523-Fortellis-Corrections-2 - Fix
2026-02-17 21:06:45 +00:00
Dave
318482c195 feature/IO-3523-Fortellis-Corrections-2 - Fix 2026-02-17 16:06:12 -05:00
Dave Richer
eea9e8e2cc Merged in release/2026-02-13 (pull request #3003)
feature/IO-3558-Reynolds-Part-2 - Send zeroed out estimate line

Approved-by: Allan Carr
2026-02-13 16:48:27 +00:00
Dave Richer
cde12f9970 Merged in feature/IO-3558-Reynolds-Part-2 (pull request #3002)
feature/IO-3558-Reynolds-Part-2 - Send zeroed out estimate line
2026-02-13 16:14:39 +00:00
Dave
48def2b74d feature/IO-3558-Reynolds-Part-2 - Send zeroed out estimate line 2026-02-13 11:13:42 -05:00
Dave Richer
dde7a99956 Merged in release/2026-02-13 (pull request #2999)
Release/2026 02 13 into master-AIO - IO-3503, IO-3510, IO-3521, IO-3533, IO-3551, IO-3556, IO-3557, IO-3558
2026-02-13 00:33:37 +00:00
Dave Richer
df964aa14e Merged in feature/IO-3558-Reynolds-Part-2 (pull request #2997)
feature/IO-3558-Reynolds-Part-2 - Prevent exporting without early ro / add a way to fake sconvert state in admin panel
2026-02-12 22:13:38 +00:00
Dave
7619360f37 feature/IO-3558-Reynolds-Part-2 - Prevent exporting without early ro / add a way to fake sconvert state in admin panel 2026-02-12 17:13:06 -05:00
Dave Richer
f15f371e86 Merged in feature/IO-3556-Chattr-Integration (pull request #2995)
6feature/IO-3556-Chattr-Integration - Move to BULLMQ stack
2026-02-12 18:00:27 +00:00
Dave
34fe0cc3bf 6feature/IO-3556-Chattr-Integration - Move to BULLMQ stack 2026-02-12 12:56:17 -05:00
Allan Carr
7acaefb5c5 Merged in feature/IO-3557-Reynold-DMS-Info (pull request #2993)
IO-3557 Reynolds DMS Info

Approved-by: Dave Richer
2026-02-12 14:26:38 +00:00
Allan Carr
ab02da47a2 IO-3557 Reynolds DMS Info
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-11 18:39:36 -08:00
Dave Richer
2a7dec90d5 Merged in feature/IO-3558-Reynolds-Part-2 (pull request #2990)
feature/IO-3558-Reynolds-Part-2 - Admin Panel
2026-02-11 23:13:22 +00:00
Dave
6e0b1f65a7 feature/IO-3558-Reynolds-Part-2 - Admin Panel 2026-02-11 18:12:56 -05:00
Dave Richer
8671d1254d Merged in feature/IO-3558-Reynolds-Part-2 (pull request #2987)
Feature/IO-3558 Reynolds Part 2
2026-02-11 22:25:29 +00:00
Dave
0ea254ed4e feature/IO-3558-Reynolds-Part-2 - Admin Panel 2026-02-11 16:57:13 -05:00
Dave
331dcfc063 feature/IO-3558-Reynolds-Part-2 - Admin Panel 2026-02-11 15:47:46 -05:00
Dave
c46804cfdf feature/IO-3558-Reynolds-Part-2 - Initial 2026-02-11 15:33:59 -05:00
Dave Richer
484d09d635 Merged in feature/IO-3556-Chattr-Integration (pull request #2986)
feature/IO-3556-Chattr-Integration - Switch Consent to true
2026-02-11 16:59:51 +00:00
Dave
188a7b47b1 feature/IO-3556-Chattr-Integration - Switch Consent to true 2026-02-11 11:59:16 -05:00
Dave Richer
a6ca93f482 Merged in feature/IO-3556-Chattr-Integration (pull request #2984)
feature/IO-3556-Chattr-Integration - Retry beef up / tweeks
2026-02-11 16:40:26 +00:00
Dave
d08bfc61cd feature/IO-3556-Chattr-Integration - Retry beef up / tweeks 2026-02-11 11:37:47 -05:00
Dave Richer
9b4de1645e Merged in feature/IO-3556-Chattr-Integration (pull request #2982)
fix
2026-02-11 14:54:33 +00:00
Dave
503c217c99 fix 2026-02-11 09:52:22 -05:00
Dave Richer
2333067e02 Merged in hotfix/2026-02-10-backend (pull request #2981)
hotfix/2026-02-10-backend - Move chatter DB stuff over
2026-02-10 23:13:50 +00:00
Dave Richer
953172493e Merged in feature/IO-3556-Chattr-Integration (pull request #2979)
hotfix/2026-02-10-backend - Move chatter DB stuff over
2026-02-10 23:05:13 +00:00
Dave
b444639fca Merge branch 'hotfix/2026-02-10-backend' into feature/IO-3556-Chattr-Integration 2026-02-10 18:03:37 -05:00
Dave
6ee7e56b9b hotfix/2026-02-10-backend - Move chatter DB stuff over 2026-02-10 17:58:26 -05:00
Dave Richer
ffd5acb21a Merged in feature/IO-3556-Chattr-Integration (pull request #2976)
Feature/IO-3556 Chattr Integration
2026-02-10 22:28:54 +00:00
Dave
0340ca5fcc feature/IO-3556-Chattr-Integration - Add in Redis caching for Chatter 2026-02-10 17:25:59 -05:00
Dave
1b2fc8b114 feature/IO-3556-Chattr-Integration 2026-02-10 17:17:44 -05:00
Dave
3745d7a414 feature/IO-3556-Chattr-Integration 2026-02-10 12:48:48 -05:00
Allan Carr
a0efac9bd8 Merged in feature/IO-3551-Export-Reports-Return-Data (pull request #2974)
IO-3551 Export Reports Return Data

Approved-by: Dave Richer
2026-02-09 15:34:22 +00:00
Allan Carr
17a772563c Merged in feature/IO-3521-Pagination-Export-Screens (pull request #2973)
IO-3521 Pagination Disable Show Size Changer

Approved-by: Dave Richer
2026-02-09 15:34:01 +00:00
Allan Carr
b1ce356bd8 IO-3551 Export Reports Return Data
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-06 20:45:44 -08:00
Allan Carr
9818cac30e IO-3521 Pagination Disable Show Size Changer
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-06 16:10:03 -08:00
Allan Carr
171277630e Merged in feature/IO-3503-Job-Costing-MASH-MAPA-Fix (pull request #2971)
IO-3503 Job Costing Fix

Approved-by: Dave Richer
2026-02-06 23:14:23 +00:00
Allan Carr
d8b400cb8c IO-3503 InstanceManager change
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-06 15:15:11 -08:00
Allan Carr
fe7bf684aa IO-3503 Job Costing Fix
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-06 15:04:00 -08:00
Dave Richer
7e6c97b3cf Merged in hotfix/2026-02-03 (pull request #2970)
Hotfix/2026 02 03
2026-02-06 22:20:58 +00:00
Allan Carr
9c6fe1905d Merged in feature/IO-3533-Actual-Cost-Click-to-Focus (pull request #2967)
IO-3533 Actual Cost Click to Focus

Approved-by: Dave Richer
2026-02-06 19:53:32 +00:00
Allan Carr
2126cccff1 IO-3533 Actual Cost Click to Focus
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-06 11:23:34 -08:00
Dave Richer
56559dd3ff Merged in hotfix/2026-02-03 (pull request #2965)
Hotfix/2026 02 03

Approved-by: Allan Carr
2026-02-03 21:49:28 +00:00
Dave
fde137d7f7 Merge branch 'feature/IO-3550-Labor-Adjustment-Popover' into hotfix/2026-02-03 2026-02-03 16:00:28 -05:00
Dave
b797bf7dc9 Merge branch 'feature/IO-3548-Bill-Modal-TabOrder' into hotfix/2026-02-03 2026-02-03 16:00:08 -05:00
Dave Richer
37c3be5cde Merged in feature/IO-3550-Labor-Adjustment-Popover (pull request #2963)
feature/IO-3550-Labor-Adjustment-Popover - Fix
2026-02-03 20:58:01 +00:00
Dave
b87d1a65fe feature/IO-3550-Labor-Adjustment-Popover - Fix 2026-02-03 15:57:24 -05:00
Dave Richer
35c832dbc3 Merged in feature/IO-3545-Production-Board-List-DND (pull request #2961)
feature/IO-3548-Bill-Modal-TabOrder
2026-02-03 20:51:19 +00:00
Dave
019b3cf4da feature/IO-3548-Bill-Modal-TabOrder 2026-02-03 15:50:48 -05:00
Dave Richer
27f4385539 Merged in feature/IO-3548-Bill-Modal-TabOrder (pull request #2959)
feature/IO-3548-Bill-Modal-TabOrder
2026-02-03 20:43:39 +00:00
Dave
ad520ab23e feature/IO-3548-Bill-Modal-TabOrder 2026-02-03 15:42:10 -05:00
Dave Richer
b3716521ec Merged in feature/IO-3545-Production-Board-List-DND (pull request #2957)
Feature/IO-3545 Production Board List DND
2026-02-03 20:30:42 +00:00
Dave
05ae0801e5 feature/IO-3545-Production-Board-List-DND - EMP assignment selector fix 2026-02-03 15:29:03 -05:00
Dave
332ade96e5 feature/IO-3545-Production-Board-List-DND - Checkpoint 2026-02-03 15:17:20 -05:00
Dave
3acec55c0e feature/IO-3545-Production-Board-List-DND - Checkpoint 2026-02-03 15:01:10 -05:00
Dave
da0462f14c feature/IO-3545-Production-Board-List-DND - Checkpoint 2026-02-03 14:56:04 -05:00
Dave
2cc9fa961e feature/IO-3545-Production-Board-List-DND - Checkpoint 2026-02-03 14:34:42 -05:00
Allan Carr
2646e85863 Merged in feature/IO-3510-Autohouse-Datapump-Enhancements (pull request #2956)
IO-3510 Autohouse Datapump Enhancements

Approved-by: Dave Richer
2026-02-03 19:20:30 +00:00
Dave
1b6fe4d18e feature/IO-3545-Production-Board-List-DND - Checkpoint 2026-02-03 13:26:17 -05:00
Dave
22aae0a7f1 feature/IO-3545-Production-Board-List-DND - Checkpoint 2026-02-03 13:21:32 -05:00
Dave Richer
cfbd6f93c3 Merged in master-AIO (pull request #2954)
Master AIO
2026-02-03 15:52:51 +00:00
Patrick Fic
db1b701a96 Merged in hotfix/2026-02-02 (pull request #2951)
Hotfix/2026 02 02

Approved-by: Dave Richer
2026-02-02 22:49:14 +00:00
Dave
2746421c09 hotfix/2026-02-02 - 2026-02-02 17:48:03 -05:00
Dave
5217120994 hotfix/2026-02-02 - Parts order manual discounting box 2026-02-02 17:39:47 -05:00
Dave
77f72a2a12 Merge branch 'hotfix/2026-02-02' of bitbucket.org:snaptsoft/bodyshop into hotfix/2026-02-02 2026-02-02 17:11:06 -05:00
Dave
a84ad4ee32 hotfix/2026-02-02 - remove check on missing line ids 2026-02-02 17:10:56 -05:00
Dave Richer
2cacd75822 Merged in bugfix/IO-3533 (pull request #2948)
bugfix/IO-3533 - Fix
2026-02-02 22:05:56 +00:00
Dave
217a0b84ac bugfix/IO-3533 - Fix 2026-02-02 17:04:06 -05:00
Dave Richer
f53ed8c427 Merged in feature/IO-3532-parts-queue-ui-adjustments (pull request #2944)
feature/IO-3532-parts-queue-ui-adjustments

Approved-by: Patrick Fic
2026-02-02 21:51:31 +00:00
Dave Richer
f8b7588a04 Merged in feature/IO-3542-fix-searches (pull request #2945)
feature/IO-3542-fix-searches

Approved-by: Patrick Fic
2026-02-02 21:46:24 +00:00
Patrick Fic
ee3cb4456d Merged in feature/IO-3531-apollo-rerender (pull request #2946)
IO-3531 remove loading on parts order page.
2026-02-02 21:45:59 +00:00
Dave
e01a2af5a4 feature/IO-3542-fix-searches 2026-02-02 16:44:49 -05:00
Dave
9c0cb5f80b Merge branch 'feature/IO-3532-parts-queue-ui-adjustments' of bitbucket.org:snaptsoft/bodyshop into feature/IO-3532-parts-queue-ui-adjustments 2026-02-02 16:15:23 -05:00
Dave
1f726aca4d feature/IO-3532-parts-queue-ui-adjustments 2026-02-02 16:14:44 -05:00
Patrick Fic
b9f398cf2d Merged in feature/IO-3532-parts-queue-ui-adjustments (pull request #2943)
IO-3532 resolve tooltip on owner name.
2026-02-02 20:57:05 +00:00
Patrick Fic
ff73a14610 IO-3532 resolve tooltip on owner name. 2026-02-02 12:55:29 -08:00
Patrick Fic
1e44d4fe42 Merged in feature/IO-3539-print-center-popovers (pull request #2941)
IO-3539 resolve print center popoves.
2026-02-02 20:38:49 +00:00
Patrick Fic
0f42875d1b IO-3539 resolve print center popoves. 2026-02-02 12:38:29 -08:00
Patrick Fic
a0f1299006 Merged in feature/IO-3538-receivec-cm-on-parts-order (pull request #2940)
IO-3538 Resolve missing id on receive return.
2026-02-02 20:23:20 +00:00
Patrick Fic
87d8a5d746 IO-3538 Resolve missing id on receive return. 2026-02-02 12:22:58 -08:00
Patrick Fic
268851902a Merged in feature/IO-3535-fed-tax-toggle-bill-posting (pull request #2935)
IO-3535 Resolve federal tax default off on received parts order.
2026-02-02 20:07:26 +00:00
Dave Richer
68bb7d2529 Merged in bugfix/IO-3533 (pull request #2937)
bugfix/IO-3533 - Disable on blurr and on focus handlers in bill entry modal

Approved-by: Patrick Fic
2026-02-02 20:06:13 +00:00
Patrick Fic
d50db12330 Merged in feature/IO-3534-bill-discrep-coloring (pull request #2936)
IO-3534 Adjust value prop to content for antd prop change to fix color display.
2026-02-02 20:05:54 +00:00
Patrick Fic
1438986c18 Merged in feature/IO-3532-parts-queue-ui-adjustments (pull request #2938)
IO-3532 Resolve parts queue pages.
2026-02-02 20:05:15 +00:00
Patrick Fic
c047699fbb Merged in feature/IO-3531-apollo-rerender (pull request #2939)
IO-3531 Change global apollo config setting to prevent rerenders.
2026-02-02 20:03:29 +00:00
Patrick Fic
cadcfc9b0d IO-3532 Resolve parts queue pages. 2026-02-02 11:21:22 -08:00
Dave
55023ceaca feature/IO-3534-bill-discrep-coloring: Remove unused console.log 2026-02-02 12:47:04 -05:00
Dave
45e143578c bugfix/IO-3533 - Disable on blurr and on focus handlers in bill entry modal 2026-02-02 12:34:54 -05:00
Patrick Fic
28a41f7637 IO-3534 Adjust value prop to content for antd prop change to fix color display. 2026-02-02 09:33:37 -08:00
Patrick Fic
2a2edeadb9 IO-3535 Resolve federal tax default off on received parts order. 2026-02-02 09:25:58 -08:00
Allan Carr
52c9b9a290 IO-3510 Autohouse Datapump Enhancements
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-27 19:20:12 -08:00
77 changed files with 6141 additions and 1069 deletions

View File

@@ -13,4 +13,5 @@
.env.development.local
.env.test.local
.env.production.local
bodyshop_translations.babel
.env.localstack.docker
bodyshop_translations.babel

File diff suppressed because it is too large Load Diff

106
client/package-lock.json generated
View File

@@ -12,6 +12,10 @@
"@amplitude/analytics-browser": "^2.34.0",
"@ant-design/pro-layout": "^7.22.6",
"@apollo/client": "^4.1.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/is-prop-valid": "^1.4.0",
"@fingerprintjs/fingerprintjs": "^5.0.1",
"@firebase/analytics": "^0.10.19",
@@ -60,7 +64,6 @@
"react-color": "^2.19.3",
"react-cookie": "^8.0.1",
"react-dom": "^19.2.4",
"react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.1",
"react-grid-layout": "^2.2.2",
"react-i18next": "^16.5.4",
@@ -2494,6 +2497,73 @@
"node": ">=10"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/modifiers": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz",
@@ -8396,16 +8466,6 @@
"@babel/types": "^7.26.0"
}
},
"node_modules/babel-runtime": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
"license": "MIT",
"dependencies": {
"core-js": "^2.4.0",
"regenerator-runtime": "^0.11.0"
}
},
"node_modules/bail": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
@@ -9192,14 +9252,6 @@
"node": ">=18"
}
},
"node_modules/core-js": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
"hasInstallScript": true,
"license": "MIT"
},
"node_modules/core-js-compat": {
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz",
@@ -15376,16 +15428,6 @@
"react": "^19.2.4"
}
},
"node_modules/react-drag-listview": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-drag-listview/-/react-drag-listview-2.0.0.tgz",
"integrity": "sha512-7Apx/1Xt4qu+JHHP0rH6aLgZgS7c2MX8ocHVGCi03KfeIWEu0t14MhT3boQKM33l5eJrE/IWfExFTvoYq22fsg==",
"license": "MIT",
"dependencies": {
"babel-runtime": "^6.26.0",
"prop-types": "^15.5.8"
}
},
"node_modules/react-draggable": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
@@ -15972,12 +16014,6 @@
"node": ">=4"
}
},
"node_modules/regenerator-runtime": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==",
"license": "MIT"
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",

View File

@@ -11,6 +11,10 @@
"@amplitude/analytics-browser": "^2.34.0",
"@ant-design/pro-layout": "^7.22.6",
"@apollo/client": "^4.1.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/is-prop-valid": "^1.4.0",
"@fingerprintjs/fingerprintjs": "^5.0.1",
"@firebase/analytics": "^0.10.19",
@@ -59,7 +63,6 @@
"react-color": "^2.19.3",
"react-cookie": "^8.0.1",
"react-dom": "^19.2.4",
"react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.1",
"react-grid-layout": "^2.2.2",
"react-i18next": "^16.5.4",

View File

@@ -446,3 +446,32 @@
//.rbc-time-header-gutter {
// padding: 0;
//}
/* globally allow shrink inside table cells */
.prod-list-table .ant-table-cell,
.prod-list-table .ant-table-cell > * {
min-width: 0;
}
/* common AntD offenders */
.prod-list-table > .ant-table-cell .ant-space,
.ant-table-cell .ant-space-item {
min-width: 0;
}
/* Keep your custom header content on the left, push AntD sorter to the far right */
.prod-list-table .ant-table-column-sorters {
display: flex !important;
align-items: center;
width: 100%;
}
.prod-list-table .ant-table-column-title {
flex: 1 1 auto;
min-width: 0; /* allows ellipsis to work */
}
.prod-list-table .ant-table-column-sorter {
margin-left: auto;
flex: 0 0 auto;
}

View File

@@ -182,7 +182,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, bills, ref
<Table
loading={loading}
dataSource={dataSource}
pagination={{ placement: "top", pageSize: exportPageLimit }}
pagination={{ placement: "top", pageSize: exportPageLimit, showSizeChanger: false }}
columns={columns}
rowKey="id"
onChange={handleTableChange}

View File

@@ -195,7 +195,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, payments,
<Table
loading={loading}
dataSource={dataSource}
pagination={{ placement: "top", pageSize: exportPageLimit }}
pagination={{ placement: "top", pageSize: exportPageLimit, showSizeChanger: false }}
columns={columns}
rowKey="id"
onChange={handleTableChange}

View File

@@ -212,7 +212,7 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
<Table
loading={loading}
dataSource={dataSource}
pagination={{ placement: "top", pageSize: exportPageLimit }}
pagination={{ placement: "top", pageSize: exportPageLimit, showSizeChanger: false }}
columns={columns}
rowKey="id"
onChange={handleTableChange}

View File

@@ -52,6 +52,9 @@ export default function BillCmdReturnsTableComponent({ form, returnLoading, retu
{fields.map((field, index) => (
<tr key={field.key}>
<td>
<Form.Item hidden key={`${index}id`} name={[field.name, "id"]}>
<ReadOnlyFormItemComponent />
</Form.Item>
<Form.Item
// label={t("joblines.fields.line_desc")}
key={`${index}line_desc`}

View File

@@ -373,9 +373,11 @@ export function BillFormComponent({
"local_tax_rate"
]);
let totals;
if (!!values.total && !!values.billlines && values.billlines.length > 0)
if (!!values.total && !!values.billlines && values.billlines.length > 0) {
totals = CalculateBillTotal(values);
if (totals)
}
if (totals) {
return (
// TODO: Align is not correct
// eslint-disable-next-line react/no-unknown-property
@@ -414,7 +416,7 @@ export function BillFormComponent({
<Statistic
title={t("bills.labels.discrepancy")}
styles={{
value: {
content: {
color: totals.discrepancy.getAmount() === 0 ? "green" : "red"
}
}}
@@ -427,6 +429,7 @@ export function BillFormComponent({
) : null}
</div>
);
}
return null;
}}
</Form.Item>

View File

@@ -1,6 +1,7 @@
import { DeleteFilled, DollarCircleFilled } from "@ant-design/icons";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Checkbox, Form, Input, InputNumber, Select, Space, Switch, Table, Tooltip } from "antd";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -32,6 +33,7 @@ export function BillEnterModalLinesComponent({
}) {
const { t } = useTranslation();
const { setFieldsValue, getFieldsValue, getFieldValue } = form;
const firstFieldRefs = useRef({});
const CONTROL_HEIGHT = 32;
@@ -90,6 +92,7 @@ export function BillEnterModalLinesComponent({
});
};
// Only fill actual_cost when the user forward-tabs out of Retail (actual_price)
const autofillActualCost = (index) => {
Promise.resolve().then(() => {
const retailRaw = form.getFieldValue(["billlines", index, "actual_price"]);
@@ -154,6 +157,9 @@ export function BillEnterModalLinesComponent({
),
formInput: (record, index) => (
<BillLineSearchSelect
ref={(el) => {
firstFieldRefs.current[index] = el;
}}
disabled={disabled}
options={lineData}
style={{
@@ -164,10 +170,9 @@ export function BillEnterModalLinesComponent({
}}
allowRemoved={form.getFieldValue("is_credit_memo") || false}
onSelect={(value, opt) => {
const d = normalizeDiscount(discount);
const retail = Number(opt.cost);
const computedActual = Number.isFinite(retail) ? round2(retail * (1 - d)) : null;
// IMPORTANT:
// Do NOT autofill actual_cost here. It should only fill when the user forward-tabs
// from Retail (actual_price) -> Actual Cost (actual_cost).
setFieldsValue({
billlines: (getFieldValue("billlines") || []).map((item, idx) => {
if (idx !== index) return item;
@@ -178,7 +183,7 @@ export function BillEnterModalLinesComponent({
quantity: opt.part_qty || 1,
actual_price: opt.cost,
original_actual_price: opt.cost,
actual_cost: isBlank(item.actual_cost) ? computedActual : item.actual_cost,
// actual_cost intentionally untouched here
cost_center: opt.part_type
? bodyshopHasDmsKey(bodyshop)
? opt.part_type !== "PAE"
@@ -205,7 +210,7 @@ export function BillEnterModalLinesComponent({
label: t("billlines.fields.line_desc"),
rules: [{ required: true }]
}),
formInput: () => <Input.TextArea disabled={disabled} autoSize />
formInput: () => <Input.TextArea disabled={disabled} autoSize tabIndex={0} />
},
{
title: t("billlines.fields.quantity"),
@@ -234,7 +239,7 @@ export function BillEnterModalLinesComponent({
})
]
}),
formInput: () => <InputNumber precision={0} min={1} disabled={disabled} />
formInput: () => <InputNumber precision={0} min={1} disabled={disabled} tabIndex={0} />
},
{
title: t("billlines.fields.actual_price"),
@@ -251,9 +256,10 @@ export function BillEnterModalLinesComponent({
<CurrencyInput
min={0}
disabled={disabled}
onBlur={() => autofillActualCost(index)}
tabIndex={0}
// NOTE: Autofill should only happen on forward Tab out of Retail
onKeyDown={(e) => {
if (e.key === "Tab") autofillActualCost(index);
if (e.key === "Tab" && !e.shiftKey) autofillActualCost(index);
}}
/>
),
@@ -328,6 +334,7 @@ export function BillEnterModalLinesComponent({
min={0}
disabled={disabled}
controls={false}
tabIndex={0}
style={{ width: "100%", height: CONTROL_HEIGHT }}
onFocus={() => autofillActualCost(index)}
/>
@@ -392,7 +399,7 @@ export function BillEnterModalLinesComponent({
rules: [{ required: true }]
}),
formInput: () => (
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled}>
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled} tabIndex={0}>
{bodyshopHasDmsKey(bodyshop)
? CiecaSelect(true, false)
: responsibilityCenters.costs.map((item) => <Select.Option key={item.name}>{item.name}</Select.Option>)}
@@ -412,7 +419,7 @@ export function BillEnterModalLinesComponent({
name: [field.name, "location"]
}),
formInput: () => (
<Select disabled={disabled}>
<Select disabled={disabled} tabIndex={0}>
{bodyshop.md_parts_locations.map((loc, idx) => (
<Select.Option key={idx} value={loc}>
{loc}
@@ -432,7 +439,7 @@ export function BillEnterModalLinesComponent({
key: `${field.name}deductedfromlbr`,
name: [field.name, "deductedfromlbr"]
}),
formInput: () => <Switch disabled={disabled} />,
formInput: () => <Switch disabled={disabled} tabIndex={0} />,
additional: (record, index) => (
<Form.Item shouldUpdate noStyle style={{ display: "inline-block" }}>
{() => {
@@ -517,9 +524,13 @@ export function BillEnterModalLinesComponent({
formItemProps: (field) => ({
key: `${field.name}fedtax`,
valuePropName: "checked",
name: [field.name, "applicable_taxes", "federal"]
name: [field.name, "applicable_taxes", "federal"],
initialValue: InstanceRenderManager({
imex: true,
rome: false
})
}),
formInput: () => <Switch disabled={disabled} />
formInput: () => <Switch disabled={disabled} tabIndex={0} />
}
]
}),
@@ -534,7 +545,7 @@ export function BillEnterModalLinesComponent({
valuePropName: "checked",
name: [field.name, "applicable_taxes", "state"]
}),
formInput: () => <Switch disabled={disabled} />
formInput: () => <Switch disabled={disabled} tabIndex={0} />
},
...InstanceRenderManager({
@@ -550,7 +561,7 @@ export function BillEnterModalLinesComponent({
valuePropName: "checked",
name: [field.name, "applicable_taxes", "local"]
}),
formInput: () => <Switch disabled={disabled} />
formInput: () => <Switch disabled={disabled} tabIndex={0} />
}
]
}),
@@ -570,6 +581,7 @@ export function BillEnterModalLinesComponent({
icon={<DeleteFilled />}
disabled={disabled || invLen > 0}
onClick={() => remove(record.name)}
tabIndex={0}
/>
{Simple_Inventory.treatment === "on" && (
@@ -641,12 +653,19 @@ export function BillEnterModalLinesComponent({
<Button
disabled={disabled}
onClick={() => {
const newIndex = fields.length;
add(
InstanceRenderManager({
imex: { applicable_taxes: { federal: true } },
rome: { applicable_taxes: { federal: false } }
})
);
setTimeout(() => {
const firstField = firstFieldRefs.current[newIndex];
if (firstField?.focus) {
firstField.focus();
}
}, 100);
}}
style={{ width: "100%" }}
>

View File

@@ -36,7 +36,7 @@ export function DashboardTotalProductionHours({ bodyshop, data, ...cardProps })
<Statistic
title={t("dashboard.labels.prodhrs")}
value={hours.total.toFixed(1)}
styles={{ value: { color: aboveTargetHours ? "green" : "red" } }}
styles={{ content: { color: aboveTargetHours ? "green" : "red" } }}
/>
</Space>
</Card>

View File

@@ -23,13 +23,13 @@ export default connect(mapStateToProps, mapDispatchToProps)(DmsCustomerSelector)
* @constructor
*/
export function DmsCustomerSelector(props) {
const { bodyshop, jobid, socket, rrOptions = {} } = props;
const { bodyshop, jobid, job, socket, rrOptions = {} } = props;
// Centralized "mode" (provider + transport)
const mode = props.mode;
// Stable base props for children
const base = useMemo(() => ({ bodyshop, jobid, socket }), [bodyshop, jobid, socket]);
const base = useMemo(() => ({ bodyshop, jobid, job, socket }), [bodyshop, jobid, job, socket]);
switch (mode) {
case DMS_MAP.reynolds: {

View File

@@ -1,4 +1,4 @@
import { Alert, Button, Checkbox, Col, message, Space, Table } from "antd";
import { Alert, Button, Checkbox, message, Modal, Space, Table } from "antd";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { alphaSort } from "../../utils/sorters";
@@ -47,6 +47,7 @@ const rrAddressToString = (addr) => {
export default function RRCustomerSelector({
jobid,
socket,
job,
rrOpenRoLimit = false,
onRrOpenRoFinished,
rrValidationPending = false,
@@ -59,15 +60,26 @@ export default function RRCustomerSelector({
const [refreshing, setRefreshing] = useState(false);
// Show dialog automatically when validation is pending
// BUT: skip this for early RO flow (job already has dms_id)
useEffect(() => {
if (rrValidationPending) setOpen(true);
}, [rrValidationPending]);
if (rrValidationPending && !job?.dms_id) {
setOpen(true);
}
}, [rrValidationPending, job?.dms_id]);
// Listen for RR customer selection list
useEffect(() => {
if (!socket) return;
const handleRrSelectCustomer = (list) => {
const normalized = normalizeRrList(list);
// If list is empty, it means early RO exists and customer selection should be skipped
// Don't open the modal in this case
if (normalized.length === 0) {
setRefreshing(false);
return;
}
setOpen(true);
setCustomerList(normalized);
const firstOwner = normalized.find((r) => r.vinOwner)?.custNo;
@@ -127,6 +139,10 @@ export default function RRCustomerSelector({
});
};
const handleClose = () => {
setOpen(false);
};
const refreshRrSearch = () => {
setRefreshing(true);
const to = setTimeout(() => setRefreshing(false), 12000);
@@ -141,8 +157,6 @@ export default function RRCustomerSelector({
socket.emit("rr-export-job", { jobId: jobid });
};
if (!open) return null;
const columns = [
{ title: t("jobs.fields.dms.id"), dataIndex: "custNo", key: "custNo" },
{
@@ -169,8 +183,45 @@ export default function RRCustomerSelector({
return !rrOwnerSet.has(String(record.custNo));
};
// For early RO flow: show validation banner even when modal is closed
if (!open) {
if (rrValidationPending && job?.dms_id) {
return (
<div style={{ marginBottom: 16 }}>
<Alert
type="info"
showIcon
title="Complete Validation in Reynolds"
description={
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div>
We created the Repair Order. Please validate the totals and taxes in the DMS system. When done,
click <strong>Finished</strong> to finalize and mark this export as complete.
</div>
<div>
<Space>
<Button type="primary" onClick={onValidationFinished}>
Finished
</Button>
</Space>
</div>
</div>
}
/>
</div>
);
}
return null;
}
return (
<Col span={24}>
<Modal
open={open}
onCancel={handleClose}
footer={null}
width={800}
title={t("dms.selectCustomer")}
>
<Table
title={() => (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
@@ -196,8 +247,8 @@ export default function RRCustomerSelector({
/>
)}
{/* Validation step banner */}
{rrValidationPending && (
{/* Validation step banner - only show for NON-early RO flow (legacy) */}
{rrValidationPending && !job?.dms_id && (
<Alert
type="info"
showIcon
@@ -262,6 +313,6 @@ export default function RRCustomerSelector({
getCheckboxProps: (record) => ({ disabled: rrDisableRow(record) })
}}
/>
</Col>
</Modal>
);
}

View File

@@ -69,7 +69,7 @@ export function DmsLogEvents({
return {
key: idx,
color: logLevelColor(level),
children: (
content: (
<Space orientation="vertical" size={4} style={{ display: "flex" }}>
{/* Row 1: summary + inline "Details" toggle */}
<Space wrap align="start">
@@ -113,7 +113,7 @@ export function DmsLogEvents({
[logs, openSet, colorizeJson, isDarkMode, showDetails]
);
return <Timeline pending reverse items={items} />;
return <Timeline reverse items={items} />;
}
/**

View File

@@ -404,7 +404,7 @@ export default function CdkLikePostForm({ bodyshop, socket, job, logsRef, mode,
<Typography.Title>=</Typography.Title>
<Statistic
title={t("jobs.labels.dms.notallocated")}
styles={{ value: { color: discrep.getAmount() === 0 ? "green" : "red" } }}
styles={{ content: { color: discrep.getAmount() === 0 ? "green" : "red" } }}
value={discrep.toFormat()}
/>
<Button disabled={disablePost} htmlType="submit">

View File

@@ -208,8 +208,18 @@ export default function RRPostForm({
});
};
// Check if early RO was created (job has all early RO fields)
const hasEarlyRO = !!(job?.dms_id && job?.dms_customer_id && job?.dms_advisor_id);
return (
<Card title={t("jobs.labels.dms.postingform")}>
{hasEarlyRO && (
<Typography.Paragraph type="success" strong style={{ marginBottom: 16 }}>
{t("jobs.labels.dms.earlyro.created")} {job.dms_id}
<br />
<Typography.Text type="secondary">{t("jobs.labels.dms.earlyro.willupdate")}</Typography.Text>
</Typography.Paragraph>
)}
<Form
form={form}
layout="vertical"
@@ -218,96 +228,96 @@ export default function RRPostForm({
initialValues={initialValues}
>
<Row gutter={[16, 12]} align="bottom">
{/* Advisor + inline Refresh */}
<Col xs={24} sm={24} md={12} lg={8}>
<Form.Item label={t("jobs.fields.dms.advisor")} required>
<Space.Compact block>
<Form.Item
name="advisorNo"
noStyle
rules={[{ required: true, message: t("general.validation.required") }]}
>
<Select
style={{ flex: 1 }}
loading={advLoading}
allowClear
placeholder={t("general.actions.select", "Select...")}
popupMatchSelectWidth
options={advisors
.map((a) => {
const value = getAdvisorNumber(a);
if (value == null) return null;
return { value: String(value), label: getAdvisorLabel(a) || String(value) };
})
.filter(Boolean)}
notFoundContent={advLoading ? t("general.labels.loading") : t("general.labels.none")}
/>
</Form.Item>
<Tooltip title={t("general.actions.refresh")}>
<Button
aria-label={t("general.actions.refresh")}
icon={<ReloadOutlined />}
onClick={() => fetchRrAdvisors(true)}
loading={advLoading}
/>
</Tooltip>
</Space.Compact>
</Form.Item>
</Col>
{/* RR OpCode (prefix / base / suffix) */}
<Col xs={24} sm={12} md={12} lg={8}>
<Form.Item
required
label={
<Space size="small" align="center">
{t("jobs.fields.dms.rr_opcode", "RR OpCode")}
{isCustomOpCode && (
{/* Advisor + inline Refresh - Only show if no early RO */}
{!hasEarlyRO && (
<Col xs={24} sm={24} md={12} lg={8}>
<Form.Item label={t("jobs.fields.dms.advisor")} required>
<Space.Compact block>
<Form.Item
name="advisorNo"
noStyle
rules={[{ required: true, message: t("general.validation.required") }]}
>
<Select
style={{ flex: 1 }}
loading={advLoading}
allowClear
placeholder={t("general.actions.select", "Select...")}
popupMatchSelectWidth
options={advisors
.map((a) => {
const value = getAdvisorNumber(a);
if (value == null) return null;
return { value: String(value), label: getAdvisorLabel(a) || String(value) };
})
.filter(Boolean)}
notFoundContent={advLoading ? t("general.labels.loading") : t("general.labels.none")}
/>
</Form.Item>
<Tooltip title={t("general.actions.refresh")}>
<Button
type="link"
size="small"
icon={<RollbackOutlined />}
onClick={handleResetOpCode}
style={{ padding: 0 }}
>
{t("jobs.fields.dms.rr_opcode_reset", "Reset")}
</Button>
)}
</Space>
}
>
<Space.Compact block>
<Form.Item name="opPrefix" noStyle>
<Input
allowClear
maxLength={4}
style={{ width: "30%" }}
placeholder={t("jobs.fields.dms.rr_opcode_prefix", "Prefix")}
/>
</Form.Item>
<Form.Item
name="opBase"
noStyle
rules={[{ required: true, message: t("general.validation.required") }]}
>
<Input
allowClear
maxLength={10}
style={{ width: "40%" }}
placeholder={t("jobs.fields.dms.rr_opcode_base", "Base")}
/>
</Form.Item>
<Form.Item name="opSuffix" noStyle>
<Input
allowClear
maxLength={4}
style={{ width: "30%" }}
placeholder={t("jobs.fields.dms.rr_opcode_suffix", "Suffix")}
/>
</Form.Item>
</Space.Compact>
</Form.Item>
</Col>
aria-label={t("general.actions.refresh")}
icon={<ReloadOutlined />}
onClick={() => fetchRrAdvisors(true)}
loading={advLoading}
/>
</Tooltip>
</Space.Compact>
</Form.Item>
</Col>
)}
{/* RR OpCode (prefix / base / suffix) - Only show if no early RO */}
{!hasEarlyRO && (
<Col xs={24} sm={12} md={12} lg={8}>
<Form.Item
required
label={
<Space size="small" align="center">
{t("jobs.fields.dms.rr_opcode", "RR OpCode")}
{isCustomOpCode && (
<Button
type="link"
size="small"
icon={<RollbackOutlined />}
onClick={handleResetOpCode}
style={{ padding: 0 }}
>
{t("jobs.fields.dms.rr_opcode_reset", "Reset")}
</Button>
)}
</Space>
}
>
<Space.Compact block>
<Form.Item name="opPrefix" noStyle>
<Input
allowClear
maxLength={4}
style={{ width: "30%" }}
placeholder={t("jobs.fields.dms.rr_opcode_prefix", "Prefix")}
/>
</Form.Item>
<Form.Item name="opBase" noStyle rules={[{ required: true }]}>
<Input
allowClear
maxLength={10}
style={{ width: "40%" }}
placeholder={t("jobs.fields.dms.rr_opcode_base", "Base")}
/>
</Form.Item>
<Form.Item name="opSuffix" noStyle>
<Input
allowClear
maxLength={4}
style={{ width: "30%" }}
placeholder={t("jobs.fields.dms.rr_opcode_suffix", "Suffix")}
/>
</Form.Item>
</Space.Compact>
</Form.Item>
</Col>
)}
<Col xs={12} sm={8} md={6} lg={4}>
<Form.Item name="kmin" label={t("jobs.fields.kmin")} initialValue={job?.kmin} rules={[{ required: true }]}>
@@ -355,13 +365,14 @@ export default function RRPostForm({
{/* Validation */}
<Form.Item shouldUpdate>
{() => {
const advisorOk = !!form.getFieldValue("advisorNo");
// When early RO exists, advisor is already set, so we don't need to validate it
const advisorOk = hasEarlyRO ? true : !!form.getFieldValue("advisorNo");
return (
<Space size="large" wrap align="center">
<Statistic title={t("jobs.labels.subtotal")} value={totals.totalSale.toFormat()} />
<Typography.Title>=</Typography.Title>
<Button disabled={!advisorOk} htmlType="submit">
{t("jobs.actions.dms.post")}
<Button disabled={!advisorOk} htmlType="submit" type={hasEarlyRO ? "default" : "primary"}>
{hasEarlyRO ? t("jobs.actions.dms.update_ro") : t("jobs.actions.dms.post")}
</Button>
</Space>
);

View File

@@ -0,0 +1,367 @@
import { ReloadOutlined } from "@ant-design/icons";
import { Alert, Button, Form, Input, InputNumber, Modal, Radio, Select, Space, Table, Typography } from "antd";
import { useEffect, useMemo, useState } from "react";
// Simple customer selector table
function CustomerSelectorTable({ customers, onSelect, isSubmitting }) {
const [selectedCustNo, setSelectedCustNo] = useState(null);
const columns = [
{
title: "Select",
key: "select",
width: 80,
render: (_, record) => (
<Radio checked={selectedCustNo === record.custNo} onChange={() => setSelectedCustNo(record.custNo)} />
)
},
{ title: "Customer ID", dataIndex: "custNo", key: "custNo" },
{ title: "Name", dataIndex: "name", key: "name" },
{
title: "VIN Owner",
key: "vinOwner",
render: (_, record) => (record.vinOwner || record.isVehicleOwner ? "Yes" : "No")
}
];
return (
<div>
<Table columns={columns} dataSource={customers} rowKey="custNo" pagination={false} size="small" />
<div style={{ marginTop: 16, display: "flex", gap: 8 }}>
<Button
type="primary"
onClick={() => onSelect(selectedCustNo, false)}
disabled={!selectedCustNo || isSubmitting}
loading={isSubmitting}
>
Use Selected Customer
</Button>
<Button onClick={() => onSelect(null, true)} disabled={isSubmitting} loading={isSubmitting}>
Create New Customer
</Button>
</div>
</div>
);
}
/**
* RR Early RO Creation Form
* Used from convert button or admin page to create minimal RO before full export
* @param bodyshop
* @param socket
* @param job
* @param onSuccess - callback when RO is created successfully
* @param onCancel - callback to close modal
* @param showCancelButton - whether to show cancel button
* @returns {JSX.Element}
* @constructor
*/
export default function RREarlyROForm({ bodyshop, socket, job, onSuccess, onCancel, showCancelButton = true }) {
const [form] = Form.useForm();
// Advisors
const [advisors, setAdvisors] = useState([]);
const [advLoading, setAdvLoading] = useState(false);
// Customer selection
const [customerCandidates, setCustomerCandidates] = useState([]);
const [showCustomerSelector, setShowCustomerSelector] = useState(false);
// Loading and success states
const [isSubmitting, setIsSubmitting] = useState(false);
const [earlyRoCreated, setEarlyRoCreated] = useState(!!job?.dms_id);
const [createdRoNumber, setCreatedRoNumber] = useState(job?.dms_id || null);
// Derive default OpCode parts from bodyshop config (matching dms.container.jsx logic)
const initialValues = useMemo(() => {
const cfg = bodyshop?.rr_configuration || {};
const defaults =
cfg.opCodeDefault ||
cfg.op_code_default ||
cfg.op_codes?.default ||
cfg.defaults?.opCode ||
cfg.defaults ||
cfg.default ||
{};
const prefix = defaults.prefix ?? defaults.opCodePrefix ?? "";
const base = defaults.base ?? defaults.opCodeBase ?? "";
const suffix = defaults.suffix ?? defaults.opCodeSuffix ?? "";
return {
kmin: job?.kmin || 0,
opPrefix: prefix,
opBase: base,
opSuffix: suffix
};
}, [bodyshop, job]);
const getAdvisorNumber = (a) => a?.advisorId;
const getAdvisorLabel = (a) => `${a?.firstName || ""} ${a?.lastName || ""}`.trim();
const fetchRrAdvisors = (refresh = false) => {
if (!socket) return;
setAdvLoading(true);
const onResult = (payload) => {
try {
const list = payload?.result ?? payload ?? [];
setAdvisors(Array.isArray(list) ? list : []);
} finally {
setAdvLoading(false);
socket.off("rr-get-advisors:result", onResult);
}
};
socket.once("rr-get-advisors:result", onResult);
socket.emit("rr-get-advisors", { departmentType: "B", refresh }, (ack) => {
if (ack?.ok) {
const list = ack.result ?? [];
setAdvisors(Array.isArray(list) ? list : []);
} else if (ack) {
console.error("Error fetching RR Advisors:", ack.error);
}
setAdvLoading(false);
socket.off("rr-get-advisors:result", onResult);
});
};
useEffect(() => {
fetchRrAdvisors(false);
}, [bodyshop?.id, socket]);
const handleStartEarlyRO = async (values) => {
if (!socket) {
console.error("Socket not available");
return;
}
setIsSubmitting(true);
const txEnvelope = {
advisorNo: values.advisorNo,
story: values.story || "",
kmin: values.kmin || job?.kmin || 0,
opPrefix: values.opPrefix || "",
opBase: values.opBase || "",
opSuffix: values.opSuffix || ""
};
// Emit the early RO creation request
socket.emit("rr-create-early-ro", {
jobId: job.id,
txEnvelope
});
// Wait for customer selection
const customerListener = (candidates) => {
console.log("Received rr-select-customer event with candidates:", candidates);
setCustomerCandidates(candidates || []);
setShowCustomerSelector(true);
setIsSubmitting(false);
socket.off("rr-select-customer", customerListener);
};
socket.once("rr-select-customer", customerListener);
// Handle failures
const failureListener = (payload) => {
if (payload?.jobId === job.id) {
console.error("Early RO creation failed:", payload.error);
alert(`Failed to create early RO: ${payload.error}`);
setIsSubmitting(false);
setShowCustomerSelector(false);
socket.off("export-failed", failureListener);
socket.off("rr-select-customer", customerListener);
}
};
socket.once("export-failed", failureListener);
};
const handleCustomerSelected = (custNo, createNew = false) => {
if (!socket) return;
console.log("handleCustomerSelected called:", { custNo, createNew, custNoType: typeof custNo });
setIsSubmitting(true);
setShowCustomerSelector(false);
const payload = {
jobId: job.id,
custNo: createNew ? null : custNo,
create: createNew
};
console.log("Emitting rr-early-customer-selected:", payload);
// Emit customer selection
socket.emit("rr-early-customer-selected", payload, (ack) => {
console.log("Received ack from rr-early-customer-selected:", ack);
setIsSubmitting(false);
if (ack?.ok) {
const roNumber = ack.dmsRoNo || ack.outsdRoNo;
setEarlyRoCreated(true);
setCreatedRoNumber(roNumber);
onSuccess?.({ roNumber, ...ack });
} else {
alert(`Failed to create early RO: ${ack?.error || "Unknown error"}`);
}
});
// Also listen for socket events
const successListener = (payload) => {
if (payload?.jobId === job.id) {
const roNumber = payload.dmsRoNo || payload.outsdRoNo;
console.log("Early RO created:", roNumber);
socket.off("rr-early-ro-created", successListener);
socket.off("export-failed", failureListener);
}
};
const failureListener = (payload) => {
if (payload?.jobId === job.id) {
console.error("Early RO creation failed:", payload.error);
setIsSubmitting(false);
setEarlyRoCreated(false);
socket.off("rr-early-ro-created", successListener);
socket.off("export-failed", failureListener);
}
};
socket.once("rr-early-ro-created", successListener);
socket.once("export-failed", failureListener);
};
// If early RO already created, show success message
if (earlyRoCreated) {
return (
<Alert
title="Early Reynolds RO Created"
description={`RO Number: ${createdRoNumber || "N/A"} - You can now convert the job.`}
type="success"
showIcon
style={{ marginBottom: 16 }}
/>
);
}
// If showing customer selector, render modal
if (showCustomerSelector) {
return (
<>
<Typography.Title level={5}>Create Early Reynolds RO</Typography.Title>
<Typography.Paragraph type="secondary">Waiting for customer selection...</Typography.Paragraph>
<Modal
title="Select Customer for Early RO"
open={true}
width={800}
footer={null}
onCancel={() => {
setShowCustomerSelector(false);
setIsSubmitting(false);
}}
>
<CustomerSelectorTable
customers={customerCandidates}
onSelect={handleCustomerSelected}
isSubmitting={isSubmitting}
/>
</Modal>
</>
);
}
// Handle manual submit (since we can't nest forms)
const handleManualSubmit = async () => {
try {
const values = await form.validateFields();
handleStartEarlyRO(values);
} catch (error) {
console.error("Validation failed:", error);
}
};
// Show the form
return (
<div style={{ marginTop: 16 }}>
<Typography.Title level={5}>Create Early Reynolds RO</Typography.Title>
<Typography.Paragraph type="secondary" style={{ fontSize: "12px" }}>
Complete this section to create a minimal RO in Reynolds before converting the job.
</Typography.Paragraph>
<Form form={form} layout="vertical" component={false} initialValues={initialValues}>
<Form.Item name="advisorNo" label="Advisor" rules={[{ required: true, message: "Please select an advisor" }]}>
<Select
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) => (option?.children?.toLowerCase() ?? "").includes(input.toLowerCase())
}}
loading={advLoading}
placeholder="Select advisor..."
popupRender={(menu) => (
<>
{menu}
<Button
type="link"
icon={<ReloadOutlined />}
onClick={() => fetchRrAdvisors(true)}
style={{ width: "100%", textAlign: "left" }}
>
Refresh Advisors
</Button>
</>
)}
>
{advisors.map((adv) => (
<Select.Option key={getAdvisorNumber(adv)} value={getAdvisorNumber(adv)}>
{getAdvisorLabel(adv)}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name="kmin"
label="Mileage In"
rules={[
{ required: true, message: "Please enter initial mileage" },
{ type: "number", min: 1, message: "Mileage must be greater than 0" }
]}
>
<InputNumber min={1} style={{ width: "100%" }} />
</Form.Item>
{/* RR OpCode (prefix / base / suffix) */}
<Form.Item required label="RR OpCode">
<Space.Compact block>
<Form.Item name="opPrefix" noStyle>
<Input allowClear maxLength={4} style={{ width: "30%" }} placeholder="Prefix" />
</Form.Item>
<Form.Item name="opBase" noStyle rules={[{ required: true, message: "Base Required" }]}>
<Input allowClear maxLength={10} style={{ width: "40%" }} placeholder="Base" />
</Form.Item>
<Form.Item name="opSuffix" noStyle>
<Input allowClear maxLength={4} style={{ width: "30%" }} placeholder="Suffix" />
</Form.Item>
</Space.Compact>
</Form.Item>
<Form.Item name="story" label="Comments / Story (Optional)">
<Input.TextArea rows={2} maxLength={240} showCount placeholder="Enter comments or story..." />
</Form.Item>
<div style={{ marginTop: 16 }}>
<Space>
<Button type="primary" onClick={handleManualSubmit} loading={isSubmitting} disabled={advLoading}>
Create Early RO
</Button>
{showCancelButton && <Button onClick={onCancel}>Cancel</Button>}
</Space>
</div>
</Form>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { Modal } from "antd";
import RREarlyROForm from "./rr-early-ro-form";
/**
* Modal wrapper for RR Early RO Creation Form
* @param open - boolean to control modal visibility
* @param onClose - callback when modal is closed
* @param onSuccess - callback when RO is created successfully
* @param bodyshop - bodyshop object
* @param socket - socket.io connection
* @param job - job object
* @returns {JSX.Element}
* @constructor
*/
export default function RREarlyROModal({ open, onClose, onSuccess, bodyshop, socket, job }) {
const handleSuccess = (result) => {
onSuccess?.(result);
onClose?.();
};
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
width={700}
destroyOnHidden
title="Create Reynolds Repair Order"
>
<RREarlyROForm bodyshop={bodyshop} socket={socket} job={job} onSuccess={handleSuccess} onCancel={onClose} />
</Modal>
);
}

View File

@@ -14,8 +14,11 @@ export default function GlobalSearch() {
const [callSearch, { loading, error, data }] = useLazyQuery(GLOBAL_SEARCH_QUERY);
const navigate = useNavigate();
const executeSearch = (v) => {
if (v && v.variables.search && v.variables.search !== "" && v.variables.search.length >= 3) callSearch(v);
const executeSearch = (variables) => {
if (variables?.search !== "" && variables?.search?.length >= 3)
callSearch({
variables
});
};
const debouncedExecuteSearch = _.debounce(executeSearch, 750);
@@ -157,7 +160,9 @@ export default function GlobalSearch() {
return (
<AutoComplete
options={options}
onSearch={handleSearch}
showSearch={{
onSearch: handleSearch
}}
defaultActiveFirstOption
onKeyDown={(e) => {
if (e.key !== "Enter") return;

View File

@@ -97,7 +97,7 @@ export function Jobd3RdPartyModal({ bodyshop, jobId, job, technician }) {
return (
<>
<Button onClick={showModal}>{t("printcenter.jobs.3rdpartypayer")}</Button>
<Modal open={isModalVisible} onOk={handleOk} onCancel={handleCancel}>
<Modal open={isModalVisible} onOk={handleOk} onCancel={handleCancel} getContainer={() => document.body}>
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
<Form.Item label={t("bills.fields.vendor")} name="vendorid">
<VendorSearchSelect options={VendorAutoCompleteData?.vendors} onSelect={handleVendorSelect} />

View File

@@ -1,29 +1,65 @@
import { useMemo } from "react";
import { Tag, Tooltip } from "antd";
import { Tooltip } from "antd";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTranslation } from "react-i18next";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
const mapDispatchToProps = () => ({});
const colorMap = {
gray: { bg: "#fafafa", border: "#d9d9d9", text: "#000000" },
gold: { bg: "#fffbe6", border: "#ffe58f", text: "#d48806" },
red: { bg: "#fff1f0", border: "#ffccc7", text: "#cf1322" },
blue: { bg: "#e6f7ff", border: "#91d5ff", text: "#0958d9" },
green: { bg: "#f6ffed", border: "#b7eb8f", text: "#389e0d" },
orange: { bg: "#fff7e6", border: "#ffd591", text: "#d46b08" }
};
function CompactTag({ color = "gray", children, tooltip = "" }) {
const colors = colorMap[color] || colorMap.gray;
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
padding: "0 2px",
fontSize: "12px",
lineHeight: "20px",
backgroundColor: colors.bg,
border: `1px solid ${colors.border}`,
borderRadius: "2px",
color: colors.text,
minWidth: "24px",
textAlign: "center"
}}
>
<Tooltip title={tooltip}>{children}</Tooltip>
</span>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(JobPartsQueueCount);
export function JobPartsQueueCount({ bodyshop, parts }) {
const { t } = useTranslation();
const partsStatus = useMemo(() => {
if (!parts) return null;
const statusKeys = ["default_bo", "default_ordered", "default_received", "default_returned"];
return parts.reduce(
(acc, val) => {
if (val.part_type === "PAS" || val.part_type === "PASL") return acc;
acc.total = acc.total + val.count;
acc[val.status] = acc[val.status] + val.count;
acc.total += val.count;
// NOTE: if val.status is null, object key becomes "null"
acc[val.status] = (acc[val.status] ?? 0) + val.count;
return acc;
},
{
@@ -34,45 +70,38 @@ export function JobPartsQueueCount({ bodyshop, parts }) {
);
}, [bodyshop, parts]);
if (!parts) return null;
if (!parts || !partsStatus) return null;
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(40px, 1fr))",
gap: "8px",
width: "100%",
justifyItems: "start"
display: "inline-flex", // ✅ shrink-wraps, fixes the “extra box” to the right
gap: 2,
alignItems: "center",
whiteSpace: "nowrap"
}}
>
<Tooltip title="Total">
<Tag style={{ minWidth: "40px", textAlign: "center" }}>{partsStatus.total}</Tag>
</Tooltip>
<Tooltip title={t("dashboard.errors.status_normal")}>
<Tag color="gold" style={{ minWidth: "40px", textAlign: "center" }}>
{partsStatus["null"]}
</Tag>
</Tooltip>
<Tooltip title={bodyshop.md_order_statuses.default_bo}>
<Tag color="red" style={{ minWidth: "40px", textAlign: "center" }}>
{partsStatus[bodyshop.md_order_statuses.default_bo]}
</Tag>
</Tooltip>
<Tooltip title={bodyshop.md_order_statuses.default_ordered}>
<Tag color="blue" style={{ minWidth: "40px", textAlign: "center" }}>
{partsStatus[bodyshop.md_order_statuses.default_ordered]}
</Tag>
</Tooltip>
<Tooltip title={bodyshop.md_order_statuses.default_received}>
<Tag color="green" style={{ minWidth: "40px", textAlign: "center" }}>
{partsStatus[bodyshop.md_order_statuses.default_received]}
</Tag>
</Tooltip>
<Tooltip title={bodyshop.md_order_statuses.default_returned}>
<Tag color="orange" style={{ minWidth: "40px", textAlign: "center" }}>
{partsStatus[bodyshop.md_order_statuses.default_returned]}
</Tag>
</Tooltip>
<CompactTag tooltip="Total" color="gray">
{partsStatus.total}
</CompactTag>
<CompactTag tooltip="No Status" color="gold">
{partsStatus["null"]}
</CompactTag>
<CompactTag tooltip={bodyshop.md_order_statuses.default_bo} color="red">
{partsStatus[bodyshop.md_order_statuses.default_bo]}
</CompactTag>
<CompactTag tooltip={bodyshop.md_order_statuses.default_ordered} color="blue">
{partsStatus[bodyshop.md_order_statuses.default_ordered]}
</CompactTag>
<CompactTag tooltip={bodyshop.md_order_statuses.default_received} color="green">
{partsStatus[bodyshop.md_order_statuses.default_received]}
</CompactTag>
<CompactTag tooltip={bodyshop.md_order_statuses.default_returned} color="orange">
{partsStatus[bodyshop.md_order_statuses.default_returned]}
</CompactTag>
</div>
);
}

View File

@@ -18,10 +18,17 @@ const mapStateToProps = createStructuredSelector({
* @param parts
* @param displayMode
* @param popoverPlacement
* @param countsOnly
* @returns {JSX.Element}
* @constructor
*/
export function JobPartsReceived({ bodyshop, parts, displayMode = "full", popoverPlacement = "top" }) {
export function JobPartsReceived({
bodyshop,
parts,
displayMode = "full",
popoverPlacement = "top",
countsOnly = false
}) {
const [open, setOpen] = useState(false);
const { t } = useTranslation();
@@ -61,6 +68,8 @@ export function JobPartsReceived({ bodyshop, parts, displayMode = "full", popove
[canOpen]
);
if (countsOnly) return <JobPartsQueueCount parts={parts} />;
const displayText =
displayMode === "compact" ? summary.percentLabel : `${summary.percentLabel} (${summary.received}/${summary.total})`;
@@ -74,7 +83,7 @@ export function JobPartsReceived({ bodyshop, parts, displayMode = "full", popove
trigger={["click"]}
placement={popoverPlacement}
content={
<div onClick={stop} style={{ minWidth: 260 }}>
<div onClick={stop}>
<JobPartsQueueCount parts={parts} />
</div>
}
@@ -99,7 +108,8 @@ JobPartsReceived.propTypes = {
bodyshop: PropTypes.object,
parts: PropTypes.array,
displayMode: PropTypes.oneOf(["full", "compact"]),
popoverPlacement: PropTypes.string
popoverPlacement: PropTypes.string,
countsOnly: PropTypes.bool
};
export default connect(mapStateToProps)(JobPartsReceived);

View File

@@ -42,11 +42,11 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
<tbody>
{fields.map((field, index) => (
<tr key={field.key}>
{/* Hidden field to preserve jobline ID */}
<Form.Item hidden name={[field.name, "id"]}>
<input />
</Form.Item>
<td>
{/* Hidden field to preserve jobline ID without injecting a div under <tr> */}
<Form.Item noStyle name={[field.name, "id"]}>
<input type="hidden" />
</Form.Item>
<Form.Item
// label={t("joblines.fields.line_desc")}
key={`${index}line_desc`}

View File

@@ -1,18 +1,22 @@
import { useMutation } from "@apollo/client/react";
import { Button, Form, Input, Popover, Select, Space, Switch } from "antd";
import { Button, Divider, Form, Input, Modal, Select, Space, Switch } from "antd";
import axios from "axios";
import { some } from "lodash";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import RREarlyROForm from "../dms-post-form/rr-early-ro-form";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
@@ -33,11 +37,27 @@ const mapDispatchToProps = (dispatch) => ({
export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTrail, parentFormIsFieldsTouched }) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [earlyRoCreated, setEarlyRoCreated] = useState(!!job?.dms_id); // Track early RO creation state
const [earlyRoCreatedThisSession, setEarlyRoCreatedThisSession] = useState(false); // Track if created in THIS modal session
const [mutationConvertJob] = useMutation(CONVERT_JOB_TO_RO);
const { t } = useTranslation();
const [form] = Form.useForm();
const notification = useNotification();
const allFormValues = Form.useWatch([], form);
const { socket } = useSocket(); // Extract socket from context
// Get Fortellis treatment for proper DMS mode detection
const {
treatments: { Fortellis }
} = useTreatmentsWithConfig({
attributes: {},
names: ["Fortellis"],
splitKey: bodyshop?.imexshopid
});
// Check if bodyshop has Reynolds integration using the proper getDmsMode function
const dmsMode = getDmsMode(bodyshop, Fortellis.treatment);
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
const handleConvert = async ({ employee_csr, category, ...values }) => {
if (parentFormIsFieldsTouched()) {
@@ -82,177 +102,227 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
const submitDisabled = useCallback(() => some(allFormValues, (v) => v === undefined), [allFormValues]);
const popMenu = (
<div>
<Form
layout="vertical"
form={form}
onFinish={handleConvert}
initialValues={{
driveable: true,
towin: job.towin,
ca_gst_registrant: job.ca_gst_registrant,
employee_csr: job.employee_csr,
category: job.category,
referral_source: job.referral_source,
referral_source_extra: job.referral_source_extra ?? ""
const handleEarlyROSuccess = (result) => {
setEarlyRoCreated(true); // Mark early RO as created
setEarlyRoCreatedThisSession(true); // Mark as created in this session
notification.success({
title: t("jobs.successes.early_ro_created"),
description: `RO Number: ${result.roNumber || "N/A"}`
});
// Delay refetch to keep success message visible for 2 seconds
setTimeout(() => {
refetch?.();
}, 2000);
};
const handleModalClose = () => {
setOpen(false);
};
if (job.converted) return <></>;
return (
<>
<Button
key="convert"
type="primary"
danger
disabled={job.converted || jobRO}
loading={loading}
onClick={() => {
setEarlyRoCreated(!!job?.dms_id); // Initialize state based on current job
setEarlyRoCreatedThisSession(false); // Reset session state when opening modal
setOpen(true);
}}
>
<Form.Item
name={["ins_co_nm"]}
label={t("jobs.fields.ins_co_nm")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
{t("jobs.actions.convert")}
</Button>
{/* Convert Job Modal */}
<Modal
open={open}
onCancel={handleModalClose}
closable={!(earlyRoCreatedThisSession && !job.converted)} // Only restrict if created in THIS session
maskClosable={!(earlyRoCreatedThisSession && !job.converted)} // Only restrict if created in THIS session
title={t("jobs.actions.convert")}
footer={null}
width={700}
destroyOnHidden
>
{/* Standard Convert Form */}
<Form
layout="vertical"
form={form}
onFinish={handleConvert}
initialValues={{
driveable: true,
towin: job.towin,
ca_gst_registrant: job.ca_gst_registrant,
employee_csr: job.employee_csr,
category: job.category,
referral_source: job.referral_source,
referral_source_extra: job.referral_source_extra ?? ""
}}
>
<Select showSearch>
{bodyshop.md_ins_cos.map((s, i) => (
<Select.Option key={i} value={s.name}>
{s.name}
</Select.Option>
))}
</Select>
</Form.Item>
{bodyshop.enforce_class && (
{/* Show Reynolds Early RO section at the top if applicable */}
{isReynoldsMode && !job.dms_id && !earlyRoCreated && (
<>
<RREarlyROForm
bodyshop={bodyshop}
socket={socket}
job={job}
onSuccess={handleEarlyROSuccess}
showCancelButton={false}
/>
<Divider />
</>
)}
<Form.Item
name={"class"}
label={t("jobs.fields.class")}
name={["ins_co_nm"]}
label={t("jobs.fields.ins_co_nm")}
rules={[
{
required: bodyshop.enforce_class
required: true
//message: t("general.validation.required"),
}
]}
>
<Select>
{bodyshop.md_classes.map((s) => (
<Select.Option key={s} value={s}>
{s}
<Select showSearch>
{bodyshop.md_ins_cos.map((s, i) => (
<Select.Option key={i} value={s.name}>
{s.name}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop.enforce_referral && (
<>
{bodyshop.enforce_class && (
<Form.Item
name={"referral_source"}
label={t("jobs.fields.referralsource")}
name={"class"}
label={t("jobs.fields.class")}
rules={[
{
required: bodyshop.enforce_referral
required: bodyshop.enforce_class
//message: t("general.validation.required"),
}
]}
>
<Select>
{bodyshop.md_referral_sources.map((s) => (
{bodyshop.md_classes.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
<Input />
</Form.Item>
</>
)}
{bodyshop.enforce_conversion_csr && (
<Form.Item
name={"employee_csr"}
label={t(
InstanceRenderManager({
imex: "jobs.fields.employee_csr",
rome: "jobs.fields.employee_csr_writer"
})
)}
rules={[
{
required: bodyshop.enforce_conversion_csr
//message: t("general.validation.required"),
}
]}
>
<Select
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}}
style={{ width: 200 }}
)}
{bodyshop.enforce_referral && (
<>
<Form.Item
name={"referral_source"}
label={t("jobs.fields.referralsource")}
rules={[
{
required: bodyshop.enforce_referral
//message: t("general.validation.required"),
}
]}
>
<Select>
{bodyshop.md_referral_sources.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
<Input />
</Form.Item>
</>
)}
{bodyshop.enforce_conversion_csr && (
<Form.Item
name={"employee_csr"}
label={t(
InstanceRenderManager({
imex: "jobs.fields.employee_csr",
rome: "jobs.fields.employee_csr_writer"
})
)}
rules={[
{
required: bodyshop.enforce_conversion_csr
//message: t("general.validation.required"),
}
]}
>
{bodyshop.employees
.filter((emp) => emp.active)
.map((emp) => (
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}>
{`${emp.first_name} ${emp.last_name}`}
<Select
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}}
style={{ width: 200 }}
>
{bodyshop.employees
.filter((emp) => emp.active)
.map((emp) => (
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}>
{`${emp.first_name} ${emp.last_name}`}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop.enforce_conversion_category && (
<Form.Item
name={"category"}
label={t("jobs.fields.category")}
rules={[
{
required: bodyshop.enforce_conversion_category
//message: t("general.validation.required"),
}
]}
>
<Select allowClear>
{bodyshop.md_categories.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop.enforce_conversion_category && (
<Form.Item
name={"category"}
label={t("jobs.fields.category")}
rules={[
{
required: bodyshop.enforce_conversion_category
//message: t("general.validation.required"),
}
]}
>
<Select allowClear>
{bodyshop.md_categories.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop.region_config.toLowerCase().startsWith("ca") && (
<Form.Item label={t("jobs.fields.ca_gst_registrant")} name="ca_gst_registrant" valuePropName="checked">
</Select>
</Form.Item>
)}
{bodyshop.region_config.toLowerCase().startsWith("ca") && (
<Form.Item label={t("jobs.fields.ca_gst_registrant")} name="ca_gst_registrant" valuePropName="checked">
<Switch />
</Form.Item>
)}
<Form.Item label={t("jobs.fields.driveable")} name="driveable" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t("jobs.fields.towin")} name="towin" valuePropName="checked">
<Switch />
</Form.Item>
)}
<Form.Item label={t("jobs.fields.driveable")} name="driveable" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t("jobs.fields.towin")} name="towin" valuePropName="checked">
<Switch />
</Form.Item>
<Space wrap>
<Button disabled={submitDisabled()} type="primary" danger onClick={() => form.submit()} loading={loading}>
{t("jobs.actions.convert")}
</Button>
<Button onClick={() => setOpen(false)}>{t("general.actions.close")}</Button>
</Space>
</Form>
</div>
);
if (job.converted) return <></>;
return (
<Popover open={open} content={popMenu}>
<Button
key="convert"
type="primary"
danger
// style={{ display: job.converted ? "none" : "" }}
disabled={job.converted || jobRO}
loading={loading}
onClick={() => {
setOpen(true);
}}
>
{t("jobs.actions.convert")}
</Button>
</Popover>
<Space wrap style={{ marginTop: 16 }}>
<Button
disabled={submitDisabled() || (isReynoldsMode && !job.dms_id && !earlyRoCreated)}
type="primary"
danger
onClick={() => form.submit()}
loading={loading}
>
{t("jobs.actions.convert")}
</Button>
<Button onClick={handleModalClose} disabled={earlyRoCreatedThisSession && !job.converted}>
{t("general.actions.close")}
</Button>
</Space>
</Form>
</Modal>
</>
);
}

View File

@@ -251,7 +251,6 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
))}
</Select>
</Form.Item>
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
<Input disabled={jobRO} />
</Form.Item>
@@ -267,6 +266,21 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
<Form.Item label={t("jobs.fields.lost_sale_reason")} name="lost_sale_reason">
<Input disabled={jobRO} allowClear />
</Form.Item>
{bodyshop.rr_dealerid && (
<Form.Item label={t("jobs.fields.dms.id")} name="dms_id">
<Input disabled />
</Form.Item>
)}
{bodyshop.rr_dealerid && (
<Form.Item label={t("jobs.fields.dms.advisor")} name="dms_advisor_id">
<Input disabled />
</Form.Item>
)}
{bodyshop.rr_dealerid && (
<Form.Item label={t("jobs.fields.dms.customer")} name="dms_customer_id">
<Input disabled />
</Form.Item>
)}
</FormRow>
</Card>
);

View File

@@ -132,7 +132,13 @@ export function LaborAllocationsAdjustmentEdit({
);
return (
<Popover open={open} onOpenChange={(vis) => setOpen(vis)} content={overlay} trigger="click">
<Popover
getPopupContainer={(trigger) => trigger?.parentElement || document.body}
open={open}
onOpenChange={(vis) => setOpen(vis)}
content={overlay}
trigger="click"
>
{children}
</Popover>
);

View File

@@ -2,6 +2,7 @@ import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { store } from "../../redux/store";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { Tooltip } from "antd";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -11,15 +12,27 @@ const mapDispatchToProps = () => ({
});
export default connect(mapStateToProps, mapDispatchToProps)(OwnerNameDisplay);
export function OwnerNameDisplay({ bodyshop, ownerObject }) {
export function OwnerNameDisplay({ bodyshop, ownerObject, withToolTip = false }) {
const emptyTest = ownerObject?.ownr_fn + ownerObject?.ownr_ln + ownerObject?.ownr_co_nm;
if (!emptyTest || emptyTest === "null" || emptyTest.trim() === "") return "N/A";
if (bodyshop.last_name_first)
return `${ownerObject?.ownr_ln || ""}, ${ownerObject?.ownr_fn || ""} ${ownerObject?.ownr_co_nm || ""}`.trim();
return `${ownerObject?.ownr_fn || ""} ${ownerObject?.ownr_ln || ""} ${ownerObject.ownr_co_nm || ""}`.trim();
let returnString;
if (bodyshop.last_name_first) {
returnString =
`${ownerObject?.ownr_ln || ""}, ${ownerObject?.ownr_fn || ""} ${ownerObject?.ownr_co_nm || ""}`.trim();
} else {
returnString = `${ownerObject?.ownr_fn || ""} ${ownerObject?.ownr_ln || ""} ${ownerObject.ownr_co_nm || ""}`.trim();
}
if (withToolTip) {
return (
<Tooltip title={returnString} mouseEnterDelay={0.5}>
{returnString}
</Tooltip>
);
} else {
return returnString;
}
}
export function OwnerNameDisplayFunction(ownerObject, forceFirstLast = false) {

View File

@@ -16,9 +16,10 @@ const OwnerSearchSelect = ({ value, onChange, onBlur, disabled, ref }) => {
SEARCH_OWNERS_BY_ID_FOR_AUTOCOMPLETE
);
const executeSearch = (v) => {
if (v && v.variables?.search !== "" && v.variables.search.length >= 2) callSearch({ variables: v.variables });
const executeSearch = (variables) => {
if (variables?.search !== "" && variables?.search?.length >= 2) callSearch({ variables });
};
const debouncedExecuteSearch = _.debounce(executeSearch, 500);
const handleSearch = (value) => {

View File

@@ -1,94 +1,121 @@
import { DownOutlined } from "@ant-design/icons";
import { Dropdown, InputNumber, Space } from "antd";
import { Button, Divider, Dropdown, InputNumber, Space, theme } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils";
const DISCOUNT_PRESETS = [5, 10, 15, 20, 25, 40];
export default function PartsOrderModalPriceChange({ form, field }) {
const { t } = useTranslation();
const menu = {
items: [
{
key: "5",
label: t("parts_orders.labels.discount", { percent: "5%" })
},
{
key: "10",
label: t("parts_orders.labels.discount", { percent: "10%" })
},
{
key: "15",
label: t("parts_orders.labels.discount", { percent: "15%" })
},
{
key: "20",
label: t("parts_orders.labels.discount", { percent: "20%" })
},
{
key: "25",
label: t("parts_orders.labels.discount", { percent: "25%" })
},
{
key: "40",
label: t("parts_orders.labels.discount", { percent: "40%" })
},
{
key: "custom",
label: (
<Space.Compact>
<InputNumber
onClick={(e) => e.stopPropagation()}
onKeyUp={(e) => {
if (e.key === "Enter") {
const values = form.getFieldsValue();
const { parts_order_lines } = values;
const { token } = theme.useToken();
form.setFieldsValue({
parts_order_lines: {
data: parts_order_lines.data.map((p, idx) => {
if (idx !== field.name) return p;
console.log(p, e.target.value, (p.act_price || 0) * ((100 - (e.target.value || 0)) / 100));
return {
...p,
act_price: (p.act_price || 0) * ((100 - (e.target.value || 0)) / 100)
};
})
}
});
e.target.value = 0;
}
}}
min={0}
max={100}
/>
<span style={{ padding: "0 11px", backgroundColor: "#fafafa", border: "1px solid #d9d9d9", borderLeft: 0 }}>%</span>
</Space.Compact>
)
const [open, setOpen] = useState(false);
const [customPercent, setCustomPercent] = useState(0);
const applyDiscountPercent = (percent) => {
const pct = Number(percent) || 0;
const values = form.getFieldsValue();
const parts_order_lines = values?.parts_order_lines;
const data = Array.isArray(parts_order_lines?.data) ? parts_order_lines.data : [];
if (!data.length) return;
form.setFieldsValue({
parts_order_lines: {
data: data.map((p, idx) => {
if (idx !== field.name) return p;
return {
...p,
act_price: (p.act_price || 0) * ((100 - pct) / 100)
};
})
}
],
});
};
const applyCustom = () => {
logImEXEvent("parts_order_manual_discount", {});
applyDiscountPercent(customPercent);
setCustomPercent(0);
setOpen(false);
};
const menu = {
// Kill the menu “card” styling so our wrapper becomes the single card.
style: {
background: "transparent",
boxShadow: "none"
},
items: DISCOUNT_PRESETS.map((pct) => ({
key: String(pct),
label: t("parts_orders.labels.discount", { percent: `${pct}%` })
})),
onClick: ({ key }) => {
logImEXEvent("parts_order_manual_discount", {});
if (key === "custom") return;
const values = form.getFieldsValue();
const { parts_order_lines } = values;
form.setFieldsValue({
parts_order_lines: {
data: parts_order_lines.data.map((p, idx) => {
if (idx !== field.name) return p;
return {
...p,
act_price: (p.act_price || 0) * ((100 - key) / 100)
};
})
}
});
applyDiscountPercent(key);
setOpen(false);
}
};
return (
<Dropdown menu={menu} trigger="click">
<Dropdown
menu={menu}
trigger={["click"]}
open={open}
onOpenChange={(nextOpen) => setOpen(nextOpen)}
getPopupContainer={(triggerNode) => triggerNode?.parentElement ?? document.body}
popupRender={(menus) => (
<div
// This makes the whole dropdown (menu + footer) look like one panel in both light/dark.
style={{
background: token.colorBgElevated,
borderRadius: token.borderRadiusLG,
boxShadow: token.boxShadowSecondary,
overflow: "hidden",
minWidth: 180
}}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{menus}
<Divider style={{ margin: 0 }} />
<div style={{ padding: token.paddingXS }}>
<Space.Compact style={{ width: "100%" }}>
<InputNumber
value={customPercent}
min={0}
max={100}
precision={0}
controls={false}
style={{ width: "100%" }}
formatter={(v) => (v === null || v === undefined ? "" : `${v}%`)}
parser={(v) =>
String(v ?? "")
.replace("%", "")
.trim()
}
onChange={(v) => setCustomPercent(v ?? 0)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
applyCustom();
}
}}
/>
<Button type="primary" onClick={applyCustom}>
{t("general.labels.apply")}
</Button>
</Space.Compact>
</div>
</div>
)}
>
<Space>
%
<DownOutlined />
% <DownOutlined />
</Space>
</Dropdown>
);

View File

@@ -93,16 +93,6 @@ export function PartsOrderModalContainer({
};
});
const missingIdx = forcedLines.findIndex((l) => !l?.job_line_id);
if (missingIdx !== -1) {
notification.error({
title: t("parts_orders.errors.creating"),
description: `Missing job_line_id for parts line #${missingIdx + 1}`
});
setSaving(false);
return;
}
let insertResult;
try {
insertResult = await insertPartOrder({

View File

@@ -1,6 +1,6 @@
import { SyncOutlined } from "@ant-design/icons";
import { useQuery } from "@apollo/client/react";
import { Button, Card, Input, Space, Table } from "antd";
import { Button, Card, Checkbox, Input, Space, Table } from "antd";
import _ from "lodash";
import queryString from "query-string";
import { useState } from "react";
@@ -31,6 +31,8 @@ export function PartsQueueListComponent({ bodyshop }) {
const { selected, sortcolumn, sortorder, statusFilters } = searchParams;
const history = useNavigate();
const [filter, setFilter] = useLocalStorage("filter_parts_queue", null);
const [viewTimeStamp, setViewTimeStamp] = useLocalStorage("parts_queue_timestamps", false);
const [countsOnly, setCountsOnly] = useLocalStorage("parts_queue_counts_only", false);
const { loading, error, data, refetch } = useQuery(QUERY_PARTS_QUEUE, {
fetchPolicy: "network-only",
@@ -92,6 +94,7 @@ export function PartsQueueListComponent({ bodyshop }) {
title: t("jobs.fields.ro_number"),
dataIndex: "ro_number",
key: "ro_number",
width: "110px",
sorter: (a, b) => alphaSort(a.ro_number, b.ro_number),
sortOrder: sortcolumn === "ro_number" && sortorder,
@@ -103,16 +106,20 @@ export function PartsQueueListComponent({ bodyshop }) {
title: t("jobs.fields.owner"),
dataIndex: "ownr_ln",
key: "ownr_ln",
width: "8%",
ellipsis: {
showTitle: true
},
sorter: (a, b) => alphaSort(OwnerNameDisplayFunction(a), OwnerNameDisplayFunction(b)),
sortOrder: sortcolumn === "ownr_ln" && sortorder,
render: (text, record) => {
return record.ownerid ? (
<Link to={"/manage/owners/" + record.ownerid}>
<OwnerNameDisplay ownerObject={record} />
<OwnerNameDisplay ownerObject={record} withToolTip />
</Link>
) : (
<span>
<OwnerNameDisplay ownerObject={record} />
<OwnerNameDisplay ownerObject={record} withToolTip />
</span>
);
}
@@ -187,7 +194,7 @@ export function PartsQueueListComponent({ bodyshop }) {
ellipsis: true,
sorter: (a, b) => dateSort(a.scheduled_in, b.scheduled_in),
sortOrder: sortcolumn === "scheduled_in" && sortorder,
render: (text, record) => <DateTimeFormatter>{record.scheduled_in}</DateTimeFormatter>
render: (text, record) => <DateTimeFormatter hideTime={!viewTimeStamp}>{record.scheduled_in}</DateTimeFormatter>
},
{
title: t("jobs.fields.scheduled_completion"),
@@ -196,7 +203,9 @@ export function PartsQueueListComponent({ bodyshop }) {
ellipsis: true,
sorter: (a, b) => dateSort(a.scheduled_completion, b.scheduled_completion),
sortOrder: sortcolumn === "scheduled_completion" && sortorder,
render: (text, record) => <DateTimeFormatter>{record.scheduled_completion}</DateTimeFormatter>
render: (text, record) => (
<DateTimeFormatter hideTime={!viewTimeStamp}>{record.scheduled_completion}</DateTimeFormatter>
)
},
// {
// title: t("vehicles.fields.plate_no"),
@@ -227,16 +236,23 @@ export function PartsQueueListComponent({ bodyshop }) {
title: t("jobs.fields.updated_at"),
dataIndex: "updated_at",
key: "updated_at",
width: "110px",
sorter: (a, b) => dateSort(a.updated_at, b.updated_at),
sortOrder: sortcolumn === "updated_at" && sortorder,
render: (text, record) => <TimeAgoFormatter>{record.updated_at}</TimeAgoFormatter>
render: (text, record) => <TimeAgoFormatter removeAgoString>{record.updated_at}</TimeAgoFormatter>
},
{
title: t("jobs.fields.partsstatus"),
dataIndex: "partsstatus",
key: "partsstatus",
width: countsOnly ? "180px" : "110px",
render: (text, record) => (
<JobPartsReceived parts={record.joblines_status} displayMode="full" popoverPlacement="topLeft" />
<JobPartsReceived
parts={record.joblines_status}
displayMode="full"
popoverPlacement="middle"
countsOnly={countsOnly}
/>
)
},
{
@@ -249,6 +265,7 @@ export function PartsQueueListComponent({ bodyshop }) {
title: t("jobs.fields.queued_for_parts"),
dataIndex: "queued_for_parts",
key: "queued_for_parts",
width: "120px",
sorter: (a, b) => a.queued_for_parts - b.queued_for_parts,
sortOrder: sortcolumn === "queued_for_parts" && sortorder,
filteredValue: filter?.queued_for_parts || null,
@@ -275,6 +292,12 @@ export function PartsQueueListComponent({ bodyshop }) {
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Checkbox checked={countsOnly} onChange={(e) => setCountsOnly(e.target.checked)}>
{t("parts.labels.view_counts_only")}
</Checkbox>
<Checkbox checked={viewTimeStamp} onChange={(e) => setViewTimeStamp(e.target.checked)}>
{t("parts.labels.view_timestamps")}
</Checkbox>
<Input.Search
className="imex-table-header__search"
placeholder={t("general.labels.search")}
@@ -299,7 +322,7 @@ export function PartsQueueListComponent({ bodyshop }) {
rowKey="id"
dataSource={jobs}
style={{ height: "100%" }}
scroll={{ x: true }}
//scroll={{ x: true }}
onChange={handleTableChange}
rowSelection={{
onSelect: (record) => {

View File

@@ -33,7 +33,7 @@ export default function PaymentFormTotalPayments({ jobid }) {
{balance && (
<Statistic
title={t("payments.labels.balance")}
styles={{ value: { color: balance.getAmount() !== 0 ? "red" : "green" } }}
styles={{ content: { color: balance.getAmount() !== 0 ? "red" : "green" } }}
value={(balance && balance.toFormat()) || ""}
/>
)}

View File

@@ -108,7 +108,7 @@ export function PrintCenterJobsLabels({ jobId }) {
</Card>
);
return (
<Popover content={content} open={isModalVisible}>
<Popover content={content} open={isModalVisible} getPopupContainer={(trigger) => trigger.parentElement}>
<Button onClick={() => setIsModalVisible(true)}>{t("printcenter.jobs.labels.labels")}</Button>
</Popover>
);

View File

@@ -35,8 +35,6 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
const result = await updateJob({
variables: { jobId: record.id, job: { [empAssignment]: employeeid } }
// awaitRefetchQueries: true,
});
insertAuditTrail({
@@ -55,6 +53,7 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
await refetch();
setAssignment({ operation: null, employeeid: null });
setLoading(false);
};
const handleRemove = async (operation) => {
@@ -84,6 +83,7 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
await refetch();
setAssignment({ operation: null, employeeid: null });
setLoading(false);
};
@@ -94,29 +94,31 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
const [visibility, setVisibility] = useState(false);
const onChange = (e, option) => {
setAssignment({ ...assignment, employeeid: e, name: option.name });
setAssignment({ ...assignment, employeeid: e, name: option.label });
};
const employeeOptions = bodyshop.employees
.filter((emp) => emp.active)
.map((emp) => ({
value: emp.id,
label: `${emp.first_name} ${emp.last_name}`,
name: `${emp.first_name} ${emp.last_name}`
}));
const popContent = (
<Row gutter={[16, 16]}>
<Col span={24}>
<Select
id="employeeSelector"
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
optionFilterProp: "label",
filterOption: (input, option) => option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
}}
style={{ width: 200 }}
value={assignment.employeeid}
onChange={onChange}
>
{bodyshop.employees
.filter((emp) => emp.active)
.map((emp) => (
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}>
{`${emp.first_name} ${emp.last_name}`}
</Select.Option>
))}
</Select>
options={employeeOptions}
/>
</Col>
<Col span={24}>
<Space wrap>
@@ -141,25 +143,25 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
if (record[type]) theEmployee = bodyshop.employees.find((e) => e.id === record[type]);
return (
<Popover destroyOnHidden content={popContent} open={visibility}>
<Spin spinning={loading}>
{record[type] ? (
<div style={{ cursor: "pointer" }}>
<span>{`${theEmployee?.first_name || ""} ${theEmployee?.last_name || ""}`}</span>
<DeleteFilled style={iconStyle} onClick={() => handleRemove(type)} />
</div>
) : (
<Spin spinning={loading}>
{record[type] ? (
<div style={{ cursor: "pointer" }}>
<span>{`${theEmployee?.first_name || ""} ${theEmployee?.last_name || ""}`}</span>
<DeleteFilled style={iconStyle} onClick={() => handleRemove(type)} />
</div>
) : (
<Popover destroyOnHidden content={popContent} open={visibility} trigger="click">
<PlusCircleFilled
style={{ ...iconStyle, cursor: "pointer" }}
className="muted-button"
onClick={() => {
setAssignment({ operation: type });
setAssignment({ operation: type, employeeid: null });
setVisibility(true);
}}
/>
)}
</Spin>
</Popover>
</Popover>
)}
</Spin>
);
}

View File

@@ -1,15 +1,19 @@
import { SyncOutlined } from "@ant-design/icons";
import { HolderOutlined, SyncOutlined } from "@ant-design/icons";
import { PageHeader } from "@ant-design/pro-layout";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Dropdown, Input, Space, Statistic, Table } from "antd";
import _ from "lodash";
import { useEffect, useMemo, useRef, useState } from "react";
import ReactDragListView from "react-drag-listview";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { closestCenter, DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { arrayMove, horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectTechnician } from "../../redux/tech/tech.selectors";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { selectDarkMode } from "../../redux/application/application.selectors.js";
import Prompt from "../../utils/prompt.js";
import AlertComponent from "../alert/alert.component.jsx";
import ProductionListColumnsAdd from "../production-list-columns/production-list-columns.add.component";
@@ -23,12 +27,81 @@ import { logImEXEvent } from "../../firebase/firebase.utils.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
technician: selectTechnician,
currentUser: selectCurrentUser
currentUser: selectCurrentUser,
isDarkMode: selectDarkMode
});
export function ProductionListTable({ loading, data, refetch, bodyshop, technician, currentUser }) {
// Draggable header cell component - combines drag and resize
function DraggableHeaderCell(props) {
const { children, columnKey, onResize, width, ...restProps } = props;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: columnKey,
disabled: !columnKey
});
const style = {
...restProps.style,
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.3 : 1,
userSelect: "none",
textAlign: "left"
};
// If no columnKey, render as regular header
if (!columnKey) {
return <ResizeableTitle {...props} />;
}
// Only apply drag listeners to elements with data-drag-handle attribute
const filteredListeners = listeners
? {
onPointerDown: (e) => {
// Only trigger drag if clicking on the drag handle
if (e.target.closest('[data-drag-handle="true"]')) {
listeners.onPointerDown?.(e);
}
}
}
: {};
// Combine drag functionality with resize
return (
<ResizeableTitle
{...restProps}
ref={setNodeRef}
style={style}
onResize={onResize}
width={width}
dragAttributes={attributes}
dragListeners={filteredListeners}
>
{children}
</ResizeableTitle>
);
}
export function ProductionListTable({ loading, data, refetch, bodyshop, technician, currentUser, isDarkMode }) {
const [searchText, setSearchText] = useState("");
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// NEW: smoother resize
const [isResizing, setIsResizing] = useState(false);
const resizeRafRef = useRef(null);
const pendingResizeRef = useRef(null);
const [activeId, setActiveId] = useState(null);
const MIN_COL_WIDTH = 20;
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 1
}
})
);
const {
treatments: { Production_List_Status_Colors, Enhanced_Payroll }
} = useTreatmentsWithConfig({
@@ -36,8 +109,10 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
names: ["Production_List_Status_Colors", "Enhanced_Payroll"],
splitKey: bodyshop.imexshopid
});
const assoc = bodyshop.associations.find((a) => a.useremail === currentUser.email);
const defaultView = assoc?.default_prod_list_view;
const initialStateRef = useRef(
(bodyshop.production_config &&
bodyshop.production_config.find((p) => p.name === defaultView)?.columns.tableState) ||
@@ -46,6 +121,7 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
filteredInfo: { text: "" }
}
);
const initialColumnsRef = useRef(
(initialStateRef.current &&
bodyshop?.production_config
@@ -66,14 +142,36 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
})) ||
[]
);
const [state, setState] = useState(initialStateRef.current);
const [columns, setColumns] = useState(initialColumnsRef.current);
const scrollX = useMemo(() => {
// keep scroll width aligned with the actual column widths so AntD doesn't clamp at a fixed floor
const sum = columns.reduce((acc, c) => acc + (c.width ?? 100), 0);
return Math.max(sum, 1);
}, [columns]);
const { t } = useTranslation();
const matchingColumnConfig = useMemo(() => {
return bodyshop?.production_config?.find((p) => p.name === defaultView);
}, [bodyshop.production_config, defaultView]);
// NEW: cleanup RAF on unmount
useEffect(() => {
return () => {
if (resizeRafRef.current) cancelAnimationFrame(resizeRafRef.current);
};
}, []);
useEffect(() => {
// NEW: while resizing, dont regenerate columns
if (isResizing) return;
// NEW: bail early BEFORE expensive ProductionListColumns(...) call
if (!_.isEqual(initialColumnsRef.current, columns)) return;
const newColumns =
matchingColumnConfig?.columns.columnKeys.map((k) => {
return {
@@ -89,10 +187,8 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
width: k.width ?? 100
};
}) || [];
// Only update columns if they haven't been manually changed by the user
if (_.isEqual(initialColumnsRef.current, columns)) {
setColumns(newColumns);
}
setColumns(newColumns);
}, [
matchingColumnConfig,
bodyshop,
@@ -102,7 +198,8 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
Production_List_Status_Colors,
refetch,
state,
columns
columns,
isResizing
]);
const handleTableChange = (pagination, filters, sorter) => {
@@ -118,17 +215,30 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
logImEXEvent("production_list_sort_filter", { pagination, filters, sorter });
};
const onDragEnd = (fromIndex, toIndex) => {
if (fromIndex === toIndex) return;
const columnsCopy = [...columns];
const [movedItem] = columnsCopy.splice(fromIndex, 1);
columnsCopy.splice(toIndex, 0, movedItem);
if (!_.isEqual(columnsCopy, columns)) {
setColumns(columnsCopy);
setHasUnsavedChanges(true);
const onDragStart = ({ active }) => {
setActiveId(active.id);
};
const onDragEnd = ({ active, over }) => {
setActiveId(null);
if (!over || active.id === over.id) return;
const oldIndex = columns.findIndex((col) => col.key === active.id);
const newIndex = columns.findIndex((col) => col.key === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const newColumns = arrayMove(columns, oldIndex, newIndex);
if (!_.isEqual(newColumns, columns)) {
setColumns(newColumns);
setHasUnsavedChanges(true);
}
}
};
const onDragCancel = () => {
setActiveId(null);
};
const removeColumn = (e) => {
const { key } = e;
const newColumns = columns.filter((i) => i.key !== key);
@@ -139,19 +249,55 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
logImEXEvent("production_list_remove_column", { key });
};
const handleResize =
(index) =>
(e, { size }) => {
const nextColumns = [...columns];
nextColumns[index] = {
...nextColumns[index],
width: size.width
};
if (!_.isEqual(nextColumns, columns)) {
setColumns(nextColumns);
// NEW: commit widths via rAF (less jank)
const applyColumnWidth = useCallback((columnKey, width) => {
const nextWidth = Math.max(MIN_COL_WIDTH, Math.round(width));
setColumns((prev) => {
const idx = prev.findIndex((c) => c.key === columnKey);
if (idx === -1) return prev;
const currentWidth = prev[idx].width ?? 100;
if (currentWidth === nextWidth) return prev;
const next = prev.slice();
next[idx] = { ...next[idx], width: nextWidth };
return next;
});
}, []);
const handleResize = useCallback(
(columnKey) =>
(e, { size }) => {
pendingResizeRef.current = { columnKey, width: size.width };
if (resizeRafRef.current) return;
resizeRafRef.current = requestAnimationFrame(() => {
resizeRafRef.current = null;
const pending = pendingResizeRef.current;
if (!pending) return;
applyColumnWidth(pending.columnKey, pending.width);
});
},
[applyColumnWidth]
);
const handleResizeStart = useCallback(() => {
setIsResizing(true);
}, []);
const handleResizeStop = useCallback(
(columnKey) =>
(e, { size }) => {
setIsResizing(false);
// Ensure final width is committed
applyColumnWidth(columnKey, size.width);
setHasUnsavedChanges(true);
}
};
logImEXEvent("production_list_resize_column", { key: columnKey, width: size.width });
},
[applyColumnWidth]
);
const addColumn = (newColumn) => {
const updatedColumns = [...columns, newColumn];
@@ -163,19 +309,53 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
};
const headerItem = (col) => {
const menu = {
onClick: removeColumn,
items: [
{
key: col.key,
label: t("production.actions.removecolumn")
}
]
};
const menu = { onClick: removeColumn, items: [{ key: col.key, label: t("production.actions.removecolumn") }] };
return (
<Dropdown className="prod-header-dropdown" menu={menu} trigger={["contextMenu"]}>
<span>{col.title}</span>
</Dropdown>
<div
style={{
display: "flex",
alignItems: "left",
width: "100%",
userSelect: "none",
minWidth: 0 // critical: allow the flex row to shrink
}}
>
<span
className="drag-handle-trigger"
data-drag-handle="true"
style={{
marginRight: 8,
color: "#999",
cursor: "grab",
padding: 4,
display: "inline-flex",
alignItems: "left",
userSelect: "none",
flex: "0 0 auto"
}}
title="Drag to reorder column"
>
<HolderOutlined />
</span>
<Dropdown className="prod-header-dropdown" menu={menu} trigger={["contextMenu"]}>
<span
style={{
flex: "1 1 auto",
minWidth: 0, // critical: allow text to shrink
overflow: "hidden", // clip
textOverflow: "ellipsis", // show …
whiteSpace: "nowrap", // keep single line
cursor: "default",
userSelect: "none",
display: "block"
}}
>
{col.title}
</span>
</Dropdown>
</div>
);
};
@@ -274,6 +454,9 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
onSave={() => {
setHasUnsavedChanges(false);
initialStateRef.current = state;
// NEW: after saving, treat current columns as the baseline
initialColumnsRef.current = columns;
}}
/>
<Input
@@ -286,60 +469,104 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
}
/>
<ProductionListDetail jobs={dataSource} />
<ReactDragListView.DragColumn onDragEnd={onDragEnd} nodeSelector="th" handleSelector=".prod-header-dropdown">
<Table
sticky
pagination={false}
size="small"
{...(Production_List_Status_Colors.treatment === "on" && {
onRow: (record, index) => {
if (!bodyshop.md_ro_statuses.production_colors) return null;
const color = bodyshop.md_ro_statuses.production_colors.find((x) => x.status === record.status);
if (!color) {
if (index % 2 === 0)
<DndContext
sensors={sensors}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
>
<SortableContext items={columns.map((col) => col.key)} strategy={horizontalListSortingStrategy}>
<Table
sticky
tableLayout="fixed"
className="prod-list-table"
pagination={false}
size="small"
{...(Production_List_Status_Colors.treatment === "on" &&
!isResizing && {
onRow: (record, index) => {
if (!bodyshop.md_ro_statuses.production_colors) return null;
const color = bodyshop.md_ro_statuses.production_colors.find((x) => x.status === record.status);
if (!color) {
if (index % 2 === 0)
return {
style: {
backgroundColor: "var(--table-row-even-bg)"
}
};
return null;
}
return {
className: "rowWithColor",
style: {
backgroundColor: "var(--table-row-even-bg)"
"--bgColor": color.color
? `rgba(${color.color.r},${color.color.g},${color.color.b},${color.color.a || 1})`
: "var(--status-row-bg-fallback)"
}
};
return null;
}
return {
className: "rowWithColor",
style: {
"--bgColor": color.color
? `rgba(${color.color.r},${color.color.g},${color.color.b},${color.color.a || 1})`
: "var(--status-row-bg-fallback)"
}
})}
components={{
header: {
cell: DraggableHeaderCell
}
}}
columns={columns.map((c) => {
return {
...c,
filteredValue: state.filteredInfo[c.key] || null,
sortOrder: state.sortedInfo.columnKey === c.key && state.sortedInfo.order,
title: headerItem(c),
ellipsis: true,
width: c.width ?? 100,
onHeaderCell: (column) => ({
columnKey: column.key,
width: column.width,
onResize: handleResize(column.key),
onResizeStart: handleResizeStart,
onResizeStop: handleResizeStop(column.key)
})
};
}
})}
components={{
header: {
cell: ResizeableTitle
}
}}
columns={columns.map((c, index) => {
return {
...c,
filteredValue: state.filteredInfo[c.key] || null,
sortOrder: state.sortedInfo.columnKey === c.key && state.sortedInfo.order,
title: headerItem(c),
ellipsis: true,
width: c.width ?? 100,
onHeaderCell: (column) => ({
width: column.width,
onResize: handleResize(index)
})
};
})}
rowKey="id"
loading={loading}
dataSource={dataSource}
scroll={{ x: 1000 }}
onChange={handleTableChange}
/>
</ReactDragListView.DragColumn>
})}
rowKey="id"
loading={loading}
dataSource={dataSource}
scroll={{ x: scrollX }}
onChange={handleTableChange}
/>
</SortableContext>
<DragOverlay dropAnimation={null}>
{activeId ? (
<div
style={{
backgroundColor: isDarkMode ? "#141414" : "white",
color: isDarkMode ? "white" : "#000",
border: `2px solid ${isDarkMode ? "#177ddc" : "#1890ff"}`,
borderRadius: "4px",
padding: "12px 16px",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.25)",
cursor: "grabbing",
display: "flex",
alignItems: "center",
fontWeight: 500,
minWidth: "120px"
}}
>
<HolderOutlined style={{ marginRight: "8px", color: isDarkMode ? "white" : "#000", fontSize: "16px" }} />
<span>
{(() => {
const col = columns.find((c) => c.key === activeId);
const title = typeof col?.title === "string" ? col.title : col?.dataIndex || col?.key || "Column";
return title;
})()}
</span>
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
);
}

View File

@@ -1,28 +1,37 @@
import { forwardRef } from "react";
import { Resizable } from "react-resizable";
import "react-resizable/css/styles.css";
export default function ResizableComponent(props) {
const { onResize, width, ...restProps } = props;
const ResizableComponent = forwardRef((props, ref) => {
const { onResize, onResizeStart, onResizeStop, width, dragAttributes, dragListeners, ...restProps } = props;
if (!width) {
return <th {...restProps} />;
return <th ref={ref} {...restProps} {...(dragAttributes || {})} {...(dragListeners || {})} />;
}
return (
<Resizable
width={width || 200}
width={width}
height={0}
onResize={onResize}
onResizeStart={onResizeStart}
onResizeStop={onResizeStop}
draggableOpts={{ enableUserSelectHack: false }}
handle={
resizeHandles={["e"]}
axis="x"
handle={(axis, handleRef) => (
<span
className="react-resizable-handle"
onClick={(e) => {
e.stopPropagation();
}}
ref={handleRef}
className={`react-resizable-handle react-resizable-handle-${axis}`}
onClick={(e) => e.stopPropagation()}
/>
}
)}
>
<th {...restProps} />
<th ref={ref} {...restProps} {...(dragAttributes || {})} {...(dragListeners || {})} />
</Resizable>
);
}
});
ResizableComponent.displayName = "ResizableComponent";
export default ResizableComponent;

View File

@@ -18,9 +18,10 @@ const VehicleSearchSelect = ({ value, onChange, onBlur, disabled, ref }) => {
SEARCH_VEHICLES_BY_ID_FOR_AUTOCOMPLETE
);
const executeSearch = (v) => {
if (v && v.variables?.search !== "" && v.variables.search.length >= 2) callSearch({ variables: v.variables });
const executeSearch = (variables) => {
if (variables?.search !== "" && variables?.search?.length >= 2) callSearch({ variables });
};
const debouncedExecuteSearch = _.debounce(executeSearch, 500);
const handleSearch = (value) => {

View File

@@ -470,6 +470,9 @@ export const GET_JOB_BY_PK = gql`
clm_total
comment
converted
dms_id
dms_customer_id
dms_advisor_id
csiinvites {
completedon
id
@@ -491,6 +494,9 @@ export const GET_JOB_BY_PK = gql`
ded_status
deliverchecklist
depreciation_taxes
dms_id
dms_advisor_id
dms_customer_id
driveable
employee_body
employee_body_rel {
@@ -1995,6 +2001,9 @@ export const QUERY_JOB_CLOSE_DETAILS = gql`
qb_multiple_payers
lbr_adjustments
ownr_ea
dms_id
dms_customer_id
dms_advisor_id
payments {
amount
created_at
@@ -2216,6 +2225,9 @@ export const QUERY_JOB_EXPORT_DMS = gql`
plate_no
plate_st
ownr_co_nm
dms_id
dms_customer_id
dms_advisor_id
}
}
`;

View File

@@ -426,6 +426,24 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
if (data.jobs_by_pk?.date_exported) return <Result status="warning" title={t("dms.errors.alreadyexported")} />;
// Check if Reynolds mode requires early RO
const hasEarlyRO = !!(data.jobs_by_pk?.dms_id && data.jobs_by_pk?.dms_customer_id && data.jobs_by_pk?.dms_advisor_id);
if (isRrMode && !hasEarlyRO) {
return (
<Result
status="warning"
title={t("dms.errors.earlyrorequired")}
subTitle={t("dms.errors.earlyrorequired.message")}
extra={
<Link to={`/manage/jobs/${jobId}/admin`}>
<Button type="primary">{t("general.actions.gotoadmin")}</Button>
</Link>
}
/>
);
}
return (
<div>
<AlertComponent style={{ marginBottom: 10 }} title={bannerMessage} type="warning" showIcon closable />
@@ -486,6 +504,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
<DmsCustomerSelector
jobid={jobId}
job={data?.jobs_by_pk}
bodyshop={bodyshop}
socket={activeSocket}
mode={mode}

View File

@@ -1,9 +1,12 @@
import { useQuery } from "@apollo/client/react";
import { Card, Col, Result, Row, Space, Typography } from "antd";
import { useEffect } from "react";
import { useMutation, useQuery } from "@apollo/client/react";
import { Button, Card, Col, Form, Input, Modal, Result, Row, Select, Space, Switch, Typography } from "antd";
import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useParams } from "react-router-dom";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { some } from "lodash";
import axios from "axios";
import AlertComponent from "../../components/alert/alert.component";
import JobCalculateTotals from "../../components/job-calculate-totals/job-calculate-totals.component";
import ScoreboardAddButton from "../../components/job-scoreboard-add-button/job-scoreboard-add-button.component";
@@ -19,13 +22,26 @@ import JobsAdminRemoveAR from "../../components/jobs-admin-remove-ar/jobs-admin-
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component";
import NotFound from "../../components/not-found/not-found.component";
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
import { GET_JOB_BY_PK } from "../../graphql/jobs.queries";
import RREarlyROModal from "../../components/dms-post-form/rr-early-ro-modal";
import { GET_JOB_BY_PK, CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket";
import { useNotification } from "../../contexts/Notifications/notificationContext";
import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
setSelectedHeader: (key) => dispatch(setSelectedHeader(key)),
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
});
const colSpan = {
@@ -39,14 +55,36 @@ const cardStyle = {
height: "100%"
};
export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop, insertAuditTrail }) {
const { jobId } = useParams();
const { loading, error, data } = useQuery(GET_JOB_BY_PK, {
const { loading, error, data, refetch } = useQuery(GET_JOB_BY_PK, {
variables: { id: jobId },
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
const { t } = useTranslation();
const { socket } = useSocket(); // Extract socket from context
const notification = useNotification();
const [showEarlyROModal, setShowEarlyROModal] = useState(false);
const [showConvertModal, setShowConvertModal] = useState(false);
const [convertLoading, setConvertLoading] = useState(false);
const [form] = Form.useForm();
const [mutationConvertJob] = useMutation(CONVERT_JOB_TO_RO);
const allFormValues = Form.useWatch([], form);
// Get Fortellis treatment for proper DMS mode detection
const {
treatments: { Fortellis }
} = useTreatmentsWithConfig({
attributes: {},
names: ["Fortellis"],
splitKey: bodyshop?.imexshopid
});
// Check if bodyshop has Reynolds integration using the proper getDmsMode function
const dmsMode = getDmsMode(bodyshop, Fortellis.treatment);
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
const job = data?.jobs_by_pk;
useEffect(() => {
setSelectedHeader("activejobs");
document.title = t("titles.jobs-admin", {
@@ -75,6 +113,55 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
]);
}, [setBreadcrumbs, t, jobId, data, setSelectedHeader]);
const handleEarlyROSuccess = (result) => {
notification.success({
title: t("jobs.successes.early_ro_created"),
description: `RO Number: ${result.roNumber || "N/A"}`
});
setShowEarlyROModal(false);
refetch?.();
};
const handleConvert = async ({ employee_csr, category, ...values }) => {
if (!job?.id) return;
setConvertLoading(true);
const res = await mutationConvertJob({
variables: {
jobId: job.id,
job: {
converted: true,
...(bodyshop?.enforce_conversion_csr ? { employee_csr } : {}),
...(bodyshop?.enforce_conversion_category ? { category } : {}),
...values
}
}
});
if (values.ca_gst_registrant) {
await axios.post("/job/totalsssu", {
id: job.id
});
}
if (!res.errors) {
refetch();
notification.success({
title: t("jobs.successes.converted")
});
insertAuditTrail({
jobid: job.id,
operation: AuditTrailMapping.jobconverted(res.data.update_jobs.returning[0].ro_number),
type: "jobconverted"
});
setShowConvertModal(false);
}
setConvertLoading(false);
};
const submitDisabled = useCallback(() => some(allFormValues, (v) => v === undefined), [allFormValues]);
if (loading) return <LoadingSpinner />;
if (error) return <AlertComponent title={error.message} type="error" />;
if (!data.jobs_by_pk) return <NotFound />;
@@ -99,6 +186,16 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
<JobsAdminUnvoid job={data ? data.jobs_by_pk : {}} />
<JobsAdminStatus job={data ? data.jobs_by_pk : {}} />
<JobsAdminRemoveAR job={data ? data.jobs_by_pk : {}} />
{isReynoldsMode && job?.converted && !job?.dms_id && !job?.dms_customer_id && !job?.dms_advisor_id && (
<Button className="ant-btn ant-btn-default" onClick={() => setShowEarlyROModal(true)}>
{t("jobs.actions.dms.createearlyro", "Create RR RO")}
</Button>
)}
{isReynoldsMode && !job?.converted && !job?.dms_id && (
<Button type="primary" danger onClick={() => setShowConvertModal(true)}>
{t("jobs.actions.convertwithoutearlyro", "Convert without Early RO")}
</Button>
)}
</Space>
</Card>
</Col>
@@ -124,8 +221,173 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
</Card>
</Col>
</Row>
{/* Early RO Modal */}
<RREarlyROModal
open={showEarlyROModal}
onClose={() => setShowEarlyROModal(false)}
onSuccess={handleEarlyROSuccess}
bodyshop={bodyshop}
socket={socket}
job={job}
/>
{/* Convert without Early RO Modal */}
<Modal
open={showConvertModal}
onCancel={() => setShowConvertModal(false)}
title={t("jobs.actions.convertwithoutearlyro", "Convert without Early RO")}
footer={null}
width={700}
destroyOnHidden
>
<Form
layout="vertical"
form={form}
onFinish={handleConvert}
initialValues={{
driveable: true,
towin: job?.towin,
ca_gst_registrant: job?.ca_gst_registrant,
employee_csr: job?.employee_csr,
category: job?.category,
referral_source: job?.referral_source,
referral_source_extra: job?.referral_source_extra ?? ""
}}
>
<Form.Item
name={["ins_co_nm"]}
label={t("jobs.fields.ins_co_nm")}
rules={[
{
required: true
}
]}
>
<Select showSearch>
{bodyshop?.md_ins_cos?.map((s, i) => (
<Select.Option key={i} value={s.name}>
{s.name}
</Select.Option>
))}
</Select>
</Form.Item>
{bodyshop?.enforce_class && (
<Form.Item
name={"class"}
label={t("jobs.fields.class")}
rules={[
{
required: bodyshop.enforce_class
}
]}
>
<Select>
{bodyshop?.md_classes?.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop?.enforce_referral && (
<>
<Form.Item
name={"referral_source"}
label={t("jobs.fields.referralsource")}
rules={[
{
required: bodyshop.enforce_referral
}
]}
>
<Select>
{bodyshop?.md_referral_sources?.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
<Input />
</Form.Item>
</>
)}
{bodyshop?.enforce_conversion_csr && (
<Form.Item
name={"employee_csr"}
label={t(
InstanceRenderManager({
imex: "jobs.fields.employee_csr",
rome: "jobs.fields.employee_csr_writer"
})
)}
rules={[
{
required: bodyshop.enforce_conversion_csr
}
]}
>
<Select
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}}
style={{ width: 200 }}
>
{bodyshop?.employees
?.filter((emp) => emp.active)
?.map((emp) => (
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}>
{`${emp.first_name} ${emp.last_name}`}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop?.enforce_conversion_category && (
<Form.Item
name={"category"}
label={t("jobs.fields.category")}
rules={[
{
required: bodyshop.enforce_conversion_category
}
]}
>
<Select allowClear>
{bodyshop?.md_categories?.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop?.region_config?.toLowerCase().startsWith("ca") && (
<Form.Item label={t("jobs.fields.ca_gst_registrant")} name="ca_gst_registrant" valuePropName="checked">
<Switch />
</Form.Item>
)}
<Form.Item label={t("jobs.fields.driveable")} name="driveable" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t("jobs.fields.towin")} name="towin" valuePropName="checked">
<Switch />
</Form.Item>
<Space wrap style={{ marginTop: 16 }}>
<Button disabled={submitDisabled()} type="primary" danger onClick={() => form.submit()} loading={convertLoading}>
{t("jobs.actions.convert")}
</Button>
<Button onClick={() => setShowConvertModal(false)}>{t("general.actions.close")}</Button>
</Space>
</Form>
</Modal>
</RbacWrapper>
);
}
export default connect(null, mapDispatchToProps)(JobsCloseContainer);
export default connect(mapStateToProps, mapDispatchToProps)(JobsCloseContainer);

View File

@@ -9,6 +9,7 @@ import {
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
@@ -42,7 +43,7 @@ import { setModalContext } from "../../redux/modals/modals.actions.js";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import dayjs from "../../utils/day";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -71,6 +72,11 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
const notification = useNotification();
const hasDMSKey = bodyshopHasDmsKey(bodyshop);
const dmsMode = getDmsMode(bodyshop, "off");
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
const hasEarlyRO = !!(job?.dms_id && job?.dms_customer_id && job?.dms_advisor_id);
const canSendToDMS = !isReynoldsMode || hasEarlyRO;
const [showEarlyROModal, setShowEarlyROModal] = useState(false);
const {
treatments: { Qb_Multi_Ar, ClosingPeriod }
@@ -82,18 +88,18 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
const handleFinish = async ({ removefromproduction, ...values }) => {
setLoading(true);
// Validate that all joblines have valid IDs
const joblinesWithIds = values.joblines.filter(jl => jl && jl.id);
const joblinesWithIds = values.joblines.filter((jl) => jl && jl.id);
if (joblinesWithIds.length !== values.joblines.length) {
notification.error({
title: t("jobs.errors.invalidjoblines"),
message: t("jobs.errors.missingjoblineids")
description: t("jobs.errors.missingjoblineids")
});
setLoading(false);
return;
}
const result = await client.mutate({
mutation: generateJobLinesUpdatesForInvoicing(values.joblines)
});
@@ -208,9 +214,17 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
</Button>
</Popconfirm>
{bodyshopHasDmsKey(bodyshop) && (
<Link to={`/manage/dms?jobId=${job.id}`}>
<Button disabled={job.date_exported || !jobRO}>{t("jobs.actions.sendtodms")}</Button>
</Link>
<>
{canSendToDMS ? (
<Link to={`/manage/dms?jobId=${job.id}`}>
<Button disabled={job.date_exported || !jobRO}>{t("jobs.actions.sendtodms")}</Button>
</Link>
) : (
<Button disabled={job.date_exported || !jobRO} onClick={() => setShowEarlyROModal(true)}>
{t("jobs.actions.sendtodms")}
</Button>
)}
</>
)}
<Button
onClick={() => {
@@ -510,7 +524,7 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
<Statistic
title={t("jobs.labels.pimraryamountpayable")}
styles={{
value: {
content: {
color: discrep.getAmount() >= 0 ? "green" : "red"
}
}}
@@ -527,6 +541,30 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
<Divider />
<JobsCloseLines job={job} />
</Form>
{/* Early RO Required Modal */}
<Modal
open={showEarlyROModal}
onCancel={() => setShowEarlyROModal(false)}
footer={null}
title={
<Space>
<Typography.Text type="warning" style={{ fontSize: "1.2em" }}>
</Typography.Text>
<span>{t("dms.errors.earlyrorequired")}</span>
</Space>
}
>
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<Typography.Paragraph>{t("dms.errors.earlyrorequired.message")}</Typography.Paragraph>
<Link to={`/manage/jobs/${job.id}/admin`}>
<Button type="primary" block onClick={() => setShowEarlyROModal(false)}>
{t("general.actions.gotoadmin")}
</Button>
</Link>
</Space>
</Modal>
</div>
);
}

View File

@@ -17,7 +17,10 @@ import "./tech.page.styles.scss";
import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component.jsx";
import { lazyDev } from "../../utils/lazyWithPreload.jsx";
const TimeTicketModalContainer = lazyDev(() => import("../../components/time-ticket-modal/time-ticket-modal.container"));
const NoteUpsertModal = lazyDev(() => import("../../components/note-upsert-modal/note-upsert-modal.container.jsx"));
const TimeTicketModalContainer = lazyDev(
() => import("../../components/time-ticket-modal/time-ticket-modal.container")
);
const EmailOverlayContainer = lazyDev(() => import("../../components/email-overlay/email-overlay.container.jsx"));
const PrintCenterModalContainer = lazyDev(
() => import("../../components/print-center-modal/print-center-modal.container")
@@ -34,7 +37,9 @@ const TimeTicketModalTask = lazyDev(
const TechAssignedProdJobs = lazyDev(() => import("../tech-assigned-prod-jobs/tech-assigned-prod-jobs.component"));
const TechDispatchedParts = lazyDev(() => import("../tech-dispatched-parts/tech-dispatched-parts.page"));
const TaskUpsertModalContainer = lazyDev(() => import("../../components/task-upsert-modal/task-upsert-modal.container"));
const TaskUpsertModalContainer = lazyDev(
() => import("../../components/task-upsert-modal/task-upsert-modal.container")
);
const { Content } = Layout;
@@ -70,6 +75,8 @@ export function TechPage({ technician }) {
<TechHeader />
<TaskUpsertModalContainer />
<NoteUpsertModal />
<Content className="tech-content-container">
<ErrorBoundary>
<Suspense

View File

@@ -48,6 +48,7 @@
"arrivedon": "Arrived on: ",
"arrivingjobs": "Arriving Jobs",
"blocked": "Blocked",
"bp": "B/P",
"cancelledappointment": "Canceled appointment for: ",
"completingjobs": "Completing Jobs",
"dataconsistency": "<0>{{ro_number}}</0> has a data consistency issue. It may have been excluded for scheduling purposes. CODE: {{code}}.",
@@ -59,18 +60,17 @@
"noarrivingjobs": "No Jobs are arriving.",
"nocompletingjobs": "No Jobs scheduled for completion.",
"nodateselected": "No date has been selected.",
"owner": "Owner",
"priorappointments": "Previous Appointments",
"reminder": "This is {{shopname}} reminding you about an appointment on {{date}} at {{time}}. Please let us know if you are not able to make the appointment. We look forward to seeing you soon. ",
"ro_number": "RO #",
"scheduled_completion": "Scheduled Completion",
"scheduledfor": "Scheduled appointment for: ",
"severalerrorsfound": "Several Jobs have issues which may prevent accurate smart scheduling. Click to expand.",
"smartscheduling": "Smart Scheduling",
"smspaymentreminder": "This is {{shopname}} reminding you about your remaining balance of {{amount}}. To pay for the said balance click the link {{payment_link}}.",
"suggesteddates": "Suggested Dates",
"ro_number": "RO #",
"owner": "Owner",
"vehicle": "Vehicle",
"bp": "B/P",
"scheduled_completion": "Scheduled Completion"
"vehicle": "Vehicle"
},
"successes": {
"canceled": "Appointment canceled successfully.",
@@ -90,6 +90,11 @@
"actions": "Actions"
}
},
"audio": {
"manager": {
"description": "Click anywhere to enable the message ding."
}
},
"audit": {
"fields": {
"cc": "CC",
@@ -149,11 +154,6 @@
"tasks_updated": "Task '{{title}}' updated by {{updatedBy}}"
}
},
"audio": {
"manager": {
"description": "Click anywhere to enable the message ding."
}
},
"billlines": {
"actions": {
"newline": "New Line"
@@ -281,9 +281,9 @@
},
"errors": {
"creatingdefaultview": "Error creating default view.",
"duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique",
"loading": "Unable to load shop details. Please call technical support.",
"saving": "Error encountered while saving. {{message}}",
"duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique"
"saving": "Error encountered while saving. {{message}}"
},
"fields": {
"ReceivableCustomField": "QBO Receivable Custom Field {{number}}",
@@ -564,21 +564,18 @@
"responsibilitycenter_tax_tier": "Tax {{typeNum}} Tier {{typeNumIterator}}",
"responsibilitycenter_tax_type": "Tax {{typeNum}} Type",
"responsibilitycenters": {
"gogcode": "GOG Code (BreakOut)",
"item_type": "Item Type",
"item_type_gog": "GOG",
"item_type_paint": "Paint Materials",
"item_type_freight": "Freight",
"taxable_flag": "Taxable?",
"taxable": "Taxable",
"nontaxable": "Non-taxable",
"ap": "Accounts Payable",
"ar": "Accounts Receivable",
"ats": "ATS",
"federal_tax": "Federal Tax",
"federal_tax_itc": "Federal Tax Credit",
"gogcode": "GOG Code (BreakOut)",
"gst_override": "GST Override Account #",
"invoiceexemptcode": "QuickBooks US - Invoice Tax Exempt Code",
"item_type": "Item Type",
"item_type_freight": "Freight",
"item_type_gog": "GOG",
"item_type_paint": "Paint Materials",
"itemexemptcode": "QuickBooks US - Line Item Tax Exempt Code",
"la1": "LA1",
"la2": "LA2",
@@ -597,6 +594,7 @@
"local_tax": "Local Tax",
"mapa": "Paint Materials",
"mash": "Shop Materials",
"nontaxable": "Non-taxable",
"paa": "Aftermarket",
"pac": "Chrome",
"pag": "Glass",
@@ -617,6 +615,8 @@
"state": "State Tax Applies"
},
"state_tax": "State Tax",
"taxable": "Taxable",
"taxable_flag": "Taxable?",
"tow": "Towing"
},
"schedule_end_time": "Schedule Ending Time",
@@ -678,8 +678,6 @@
"zip_post": "Zip/Postal Code"
},
"labels": {
"parts_shop_management": "Shop Management",
"parts_vendor_management": "Vendor Management",
"2tiername": "Name => RO",
"2tiersetup": "2 Tier Setup",
"2tiersource": "Source => RO",
@@ -702,11 +700,11 @@
"payers": "Payers"
},
"cdk_dealerid": "CDK Dealer ID",
"rr_dealerid": "Reynolds Store Number",
"costsmapping": "Costs Mapping",
"dms_allocations": "DMS Allocations",
"pbs_serialnumber": "PBS Serial Number",
"profitsmapping": "Profits Mapping",
"rr_dealerid": "Reynolds Store Number",
"title": "DMS"
},
"emaillater": "Email Later",
@@ -733,6 +731,8 @@
"followers": "Notifications"
},
"orderstatuses": "Order Statuses",
"parts_shop_management": "Shop Management",
"parts_vendor_management": "Vendor Management",
"partslocations": "Parts Locations",
"partsscan": "Parts Scanning",
"printlater": "Print Later",
@@ -1047,7 +1047,9 @@
},
"dms": {
"errors": {
"alreadyexported": "This job has already been sent to the DMS. If you need to resend it, please use admin permissions to mark the job for re-export."
"alreadyexported": "This job has already been sent to the DMS. If you need to resend it, please use admin permissions to mark the job for re-export.",
"earlyrorequired": "Early RO Required",
"earlyrorequired.message": "This job requires an early Repair Order to be created before posting to Reynolds. Please use the admin panel to create the early RO first."
},
"labels": {
"refreshallocations": "Refresh to see DMS Allocations."
@@ -1228,8 +1230,6 @@
},
"general": {
"actions": {
"select": "Select",
"optional": "Optional",
"add": "Add",
"autoupdate": "{{app}} will automatically update in {{time}} seconds. Please save all changes.",
"calculate": "Calculate",
@@ -1246,9 +1246,11 @@
"deselectall": "Deselect All",
"download": "Download",
"edit": "Edit",
"gotoadmin": "Go to Admin Panel",
"login": "Login",
"next": "Next",
"ok": "Ok",
"optional": "Optional",
"previous": "Previous",
"print": "Print",
"refresh": "Refresh",
@@ -1259,6 +1261,7 @@
"save": "Save",
"saveandnew": "Save and New",
"saveas": "Save As",
"select": "Select",
"selectall": "Select All",
"send": "Send",
"sendbysms": "Send by SMS",
@@ -1288,8 +1291,7 @@
"vehicle": "Vehicle"
},
"labels": {
"selected": "Selected",
"settings": "Settings",
"apply": "Apply",
"actions": "Actions",
"areyousure": "Are you sure?",
"barcode": "Barcode",
@@ -1343,8 +1345,10 @@
"search": "Search...",
"searchresults": "Results for {{search}}",
"selectdate": "Select date...",
"selected": "Selected",
"sendagain": "Send Again",
"sendby": "Send By",
"settings": "Settings",
"signin": "Sign In",
"sms": "SMS",
"status": "Status",
@@ -1587,13 +1591,13 @@
"labels": {
"adjustmenttobeadded": "Adjustment to be added: {{adjustment}}",
"billref": "Latest Bill",
"bulk_location_help": "This will set the same location on all selected lines.",
"convertedtolabor": "This line has been converted to labor. Ensure you adjust the profit center for the amount accordingly.",
"edit": "Edit Line",
"ioucreated": "IOU",
"new": "New Line",
"nostatus": "No Status",
"presets": "Jobline Presets",
"bulk_location_help": "This will set the same location on all selected lines."
"presets": "Jobline Presets"
},
"successes": {
"created": "Job line created successfully.",
@@ -1621,11 +1625,13 @@
"changestatus": "Change Status",
"changestimator": "Change Estimator",
"convert": "Convert",
"convertwithoutearlyro": "Convert without Early RO",
"createiou": "Create IOU",
"deliver": "Deliver",
"deliver_quick": "Quick Deliver",
"dms": {
"addpayer": "Add Payer",
"createearlyro": "Create RR RO",
"createnewcustomer": "Create New Customer",
"findmakemodelcode": "Find Make/Model Code",
"getmakes": "Get Makes",
@@ -1634,6 +1640,7 @@
},
"post": "Post",
"refetchmakesmodels": "Refetch Make and Model Codes",
"update_ro": "Update RO",
"usegeneric": "Use Generic Customer",
"useselected": "Use Selected Customer"
},
@@ -1701,9 +1708,9 @@
"actual_delivery": "Actual Delivery",
"actual_in": "Actual In",
"acv_amount": "ACV Amount",
"admin_clerk": "Admin Clerk",
"adjustment_bottom_line": "Adjustments",
"adjustmenthours": "Adjustment Hours",
"admin_clerk": "Admin Clerk",
"alt_transport": "Alt. Trans.",
"area_of_damage_impact": {
"10": "Left Front Side",
@@ -1784,9 +1791,8 @@
"ded_status": "Deductible Status",
"depreciation_taxes": "Betterment/Depreciation/Taxes",
"dms": {
"first_name": "First Name",
"last_name": "Last Name",
"address": "Customer Address",
"advisor": "Advisor #",
"amount": "Amount",
"center": "Center",
"control_type": {
@@ -1794,17 +1800,19 @@
},
"cost": "Cost",
"cost_dms_acctnumber": "Cost DMS Acct #",
"customer": "Customer #",
"dms_make": "DMS Make",
"dms_model": "DMS Model",
"dms_model_override": "Override DMS Make/Model",
"dms_unsold": "New, Unsold Vehicle",
"dms_wip_acctnumber": "Cost WIP DMS Acct #",
"first_name": "First Name",
"id": "DMS ID",
"inservicedate": "In Service Date",
"journal": "Journal #",
"make_override": "Make Override",
"advisor": "Advisor #",
"last_name": "Last Name",
"lines": "Posting Lines",
"make_override": "Make Override",
"name1": "Customer Name",
"payer": {
"amount": "Amount",
@@ -1817,7 +1825,11 @@
"sale": "Sale",
"sale_dms_acctnumber": "Sale DMS Acct #",
"story": "Story",
"vinowner": "VIN Owner"
"vinowner": "VIN Owner",
"rr_opcode": "RR OpCode",
"rr_opcode_prefix": "Prefix",
"rr_opcode_suffix": "Suffix",
"rr_opcode_base": "Base"
},
"dms_allocation": "DMS Allocation",
"driveable": "Driveable",
@@ -1945,7 +1957,7 @@
"amount": "Amount",
"name": "Name"
},
"queued_for_parts": "Queued for Parts",
"queued_for_parts": "Queued",
"rate_ats": "ATS Rate",
"rate_ats_flat": "ATS Flat Rate",
"rate_la1": "LA1",
@@ -2102,6 +2114,11 @@
"damageto": "Damage to $t(jobs.fields.area_of_damage_impact.{{area_of_damage}}).",
"defaultstory": "B/S RO: {{ro_number}}. Owner: {{ownr_nm}}. Insurance Co: {{ins_co_nm}}. Claim/PO #: {{clm_po}}",
"disablebillwip": "Cost and WIP for bills has been ignored per shop configuration.",
"earlyro": {
"created": "Early RO Created:",
"fields": "Required fields:",
"willupdate": "This will update the existing RO with full job data."
},
"invoicedatefuture": "Invoice date must be today or in the future for CDK posting.",
"kmoutnotgreaterthankmin": "Mileage out must be greater than mileage in.",
"logs": "Logs",
@@ -2259,6 +2276,7 @@
"delete": "Job deleted successfully.",
"deleted": "Job deleted successfully.",
"duplicated": "Job duplicated successfully. ",
"early_ro_created": "Early RO Created",
"exported": "Job(s) exported successfully. ",
"invoiced": "Job closed and invoiced successfully.",
"ioucreated": "IOU created successfully. Click to see.",
@@ -2447,6 +2465,7 @@
"labels": {
"addlabel": "Add a label to this conversation.",
"archive": "Archive",
"mark_unread": "Mark as unread",
"maxtenimages": "You can only select up to a maximum of 10 images at a time.",
"messaging": "Messaging",
"no_consent": "Opted-out",
@@ -2459,8 +2478,7 @@
"selectmedia": "Select Media",
"sentby": "Sent by {{by}} at {{time}}",
"typeamessage": "Send a message...",
"unarchive": "Unarchive",
"mark_unread": "Mark as unread"
"unarchive": "Unarchive"
},
"render": {
"conversation_list": "Conversation List"
@@ -2614,20 +2632,20 @@
"name": "Owner Details"
},
"labels": {
"cell": "Cell",
"create_new": "Create a new owner record.",
"deleteconfirm": "Are you sure you want to delete this owner? This cannot be undone.",
"email": "Email",
"existing_owners": "Existing Owners",
"fromclaim": "Current Claim",
"fromowner": "Historical Owner Record",
"relatedjobs": "Related Jobs",
"updateowner": "Update Owner",
"work": "Work",
"home": "Home",
"cell": "Cell",
"other": "Other",
"email": "Email",
"phone": "Phone",
"sms": "SMS"
"relatedjobs": "Related Jobs",
"sms": "SMS",
"updateowner": "Update Owner",
"work": "Work"
},
"successes": {
"delete": "Owner deleted successfully.",
@@ -2638,6 +2656,10 @@
"actions": {
"order": "Order Parts",
"orderinhouse": "Order as In House"
},
"labels": {
"view_counts_only": "View Parts Counts Only",
"view_timestamps": "Show timestamps"
}
},
"parts_dispatch": {
@@ -2987,8 +3009,6 @@
"settings": "Error saving board settings: {{error}}"
},
"labels": {
"click_for_statuses": "Click to view parts statuses",
"partsreceived": "Parts Received",
"actual_in": "Actual In",
"addnewprofile": "Add New Profile",
"alert": "Alert",
@@ -3007,6 +3027,7 @@
"card_size": "Card Size",
"cardcolor": "Colored Cards",
"cardsettings": "Card Settings",
"click_for_statuses": "Click to view parts statuses",
"clm_no": "Claim Number",
"comment": "Comment",
"compact": "Compact Cards",
@@ -3027,6 +3048,7 @@
"orientation": "Board Orientation",
"ownr_nm": "Customer Name",
"paintpriority": "P/P",
"partsreceived": "Parts Received",
"partsstatus": "Parts Status",
"production_note": "Production Note",
"refinishhours": "R",
@@ -3573,17 +3595,12 @@
}
},
"titles": {
"parts_settings": "Parts Management Settings | {{app}}",
"simplified-parts-jobs": "Parts Management | {{app}}",
"accounting-payables": "Payables | {{app}}",
"accounting-payments": "Payments | {{app}}",
"accounting-receivables": "Receivables | {{app}}",
"all_tasks": "All Tasks | {{app}}",
"app": "",
"bc": {
"simplified-parts-jobs": "Jobs",
"parts": "Parts",
"parts_settings": "Settings",
"accounting-payables": "Payables",
"accounting-payments": "Payments",
"accounting-receivables": "Receivables",
@@ -3615,7 +3632,9 @@
"my_tasks": "My Tasks",
"owner-detail": "{{name}}",
"owners": "Owners",
"parts": "Parts",
"parts-queue": "Parts Queue",
"parts_settings": "Settings",
"payments-all": "All Payments",
"phonebook": "Phonebook",
"productionboard": "Production Board - Visual",
@@ -3627,6 +3646,7 @@
"shop-csi": "CSI Responses",
"shop-templates": "Shop Templates",
"shop-vendors": "Vendors",
"simplified-parts-jobs": "Jobs",
"tasks": "Tasks",
"temporarydocs": "Temporary Documents",
"timetickets": "Time Tickets",
@@ -3662,7 +3682,9 @@
"my_tasks": "My Tasks | {{app}}",
"owners": "All Owners | {{app}}",
"owners-detail": "{{name}} | {{app}}",
"parts": "",
"parts-queue": "Parts Queue | {{app}}",
"parts_settings": "Parts Management Settings | {{app}}",
"payments-all": "Payments | {{app}}",
"phonebook": "Phonebook | {{app}}",
"productionboard": "Production Board - Visual | {{app}}",
@@ -3678,6 +3700,7 @@
"shop-csi": "CSI Responses | {{app}}",
"shop-templates": "Shop Templates | {{app}}",
"shop_vendors": "Vendors | {{app}}",
"simplified-parts-jobs": "Parts Management | {{app}}",
"tasks": "Tasks",
"techconsole": "Technician Console | {{app}}",
"techjobclock": "Technician Job Clock | {{app}}",
@@ -3838,10 +3861,10 @@
"user": {
"actions": {
"changepassword": "Change Password",
"signout": "Sign Out",
"updateprofile": "Update Profile",
"dark_theme": "Switch to Dark Theme",
"light_theme": "Switch to Light Theme",
"dark_theme": "Switch to Dark Theme"
"signout": "Sign Out",
"updateprofile": "Update Profile"
},
"errors": {
"updating": "Error updating user or association {{message}}"
@@ -3855,14 +3878,14 @@
"labels": {
"actions": "Actions",
"changepassword": "Change Password",
"profileinfo": "Profile Info",
"user_settings": "User Settings",
"play_sound_for_new_messages": "Play a sound for new messages",
"notification_sound_on": "Sound is ON",
"notification_sound_off": "Sound is OFF",
"notification_sound_enabled": "Notification sound enabled",
"notification_sound_disabled": "Notification sound disabled",
"notification_sound_help": "Toggle the ding for incoming chat messages."
"notification_sound_enabled": "Notification sound enabled",
"notification_sound_help": "Toggle the ding for incoming chat messages.",
"notification_sound_off": "Sound is OFF",
"notification_sound_on": "Sound is ON",
"play_sound_for_new_messages": "Play a sound for new messages",
"profileinfo": "Profile Info",
"user_settings": "User Settings"
},
"successess": {
"passwordchanged": "Password changed successfully. "

View File

@@ -48,6 +48,7 @@
"arrivedon": "Llegado el:",
"arrivingjobs": "",
"blocked": "",
"bp": "",
"cancelledappointment": "Cita cancelada para:",
"completingjobs": "",
"dataconsistency": "",
@@ -59,18 +60,17 @@
"noarrivingjobs": "",
"nocompletingjobs": "",
"nodateselected": "No se ha seleccionado ninguna fecha.",
"owner": "",
"priorappointments": "Nombramientos previos",
"reminder": "",
"ro_number": "",
"scheduled_completion": "",
"scheduledfor": "Cita programada para:",
"severalerrorsfound": "",
"smartscheduling": "",
"smspaymentreminder": "",
"suggesteddates": "",
"ro_number": "",
"owner": "",
"vehicle": "",
"bp": "",
"scheduled_completion": ""
"vehicle": ""
},
"successes": {
"canceled": "Cita cancelada con éxito.",
@@ -90,6 +90,11 @@
"actions": "Comportamiento"
}
},
"audio": {
"manager": {
"description": ""
}
},
"audit": {
"fields": {
"cc": "",
@@ -149,11 +154,6 @@
"tasks_updated": ""
}
},
"audio": {
"manager": {
"description": ""
}
},
"billlines": {
"actions": {
"newline": ""
@@ -281,9 +281,9 @@
},
"errors": {
"creatingdefaultview": "",
"duplicate_insurance_company": "",
"loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.",
"saving": "",
"duplicate_insurance_company": ""
"saving": ""
},
"fields": {
"ReceivableCustomField": "",
@@ -564,21 +564,18 @@
"responsibilitycenter_tax_tier": "",
"responsibilitycenter_tax_type": "",
"responsibilitycenters": {
"gogcode": "",
"item_type": "Item Type",
"item_type_gog": "",
"item_type_paint": "",
"item_type_freight": "",
"taxable_flag": "",
"taxable": "",
"nontaxable": "",
"ap": "",
"ar": "",
"ats": "",
"federal_tax": "",
"federal_tax_itc": "",
"gogcode": "",
"gst_override": "",
"invoiceexemptcode": "",
"item_type": "Item Type",
"item_type_freight": "",
"item_type_gog": "",
"item_type_paint": "",
"itemexemptcode": "",
"la1": "",
"la2": "",
@@ -597,6 +594,7 @@
"local_tax": "",
"mapa": "",
"mash": "",
"nontaxable": "",
"paa": "",
"pac": "",
"pag": "",
@@ -617,6 +615,8 @@
"state": ""
},
"state_tax": "",
"taxable": "",
"taxable_flag": "",
"tow": ""
},
"schedule_end_time": "",
@@ -678,8 +678,6 @@
"zip_post": ""
},
"labels": {
"parts_shop_management": "",
"parts_vendor_management": "",
"2tiername": "",
"2tiersetup": "",
"2tiersource": "",
@@ -702,11 +700,11 @@
"payers": ""
},
"cdk_dealerid": "",
"rr_dealerid": "",
"costsmapping": "",
"dms_allocations": "",
"pbs_serialnumber": "",
"profitsmapping": "",
"rr_dealerid": "",
"title": ""
},
"emaillater": "",
@@ -733,6 +731,8 @@
"followers": ""
},
"orderstatuses": "",
"parts_shop_management": "",
"parts_vendor_management": "",
"partslocations": "",
"partsscan": "",
"printlater": "",
@@ -1047,7 +1047,9 @@
},
"dms": {
"errors": {
"alreadyexported": ""
"alreadyexported": "",
"earlyrorequired": "",
"earlyrorequired.message": ""
},
"labels": {
"refreshallocations": ""
@@ -1244,9 +1246,11 @@
"deselectall": "",
"download": "",
"edit": "Editar",
"gotoadmin": "",
"login": "",
"next": "",
"ok": "",
"optional": "",
"previous": "",
"print": "",
"refresh": "",
@@ -1257,6 +1261,7 @@
"save": "Salvar",
"saveandnew": "",
"saveas": "",
"select": "",
"selectall": "",
"send": "",
"sendbysms": "",
@@ -1286,9 +1291,8 @@
"vehicle": ""
},
"labels": {
"selected": "",
"apply": "",
"actions": "Comportamiento",
"settings": "",
"areyousure": "",
"barcode": "código de barras",
"cancel": "",
@@ -1341,8 +1345,10 @@
"search": "Buscar...",
"searchresults": "",
"selectdate": "",
"selected": "",
"sendagain": "",
"sendby": "",
"settings": "",
"signin": "",
"sms": "",
"status": "",
@@ -1585,13 +1591,13 @@
"labels": {
"adjustmenttobeadded": "",
"billref": "",
"bulk_location_help": "",
"convertedtolabor": "",
"edit": "Línea de edición",
"ioucreated": "",
"new": "Nueva línea",
"nostatus": "",
"presets": "",
"bulk_location_help": ""
"presets": ""
},
"successes": {
"created": "",
@@ -1619,11 +1625,13 @@
"changestatus": "Cambiar Estado",
"changestimator": "",
"convert": "Convertir",
"convertwithoutearlyro": "",
"createiou": "",
"deliver": "",
"deliver_quick": "",
"dms": {
"addpayer": "",
"createearlyro": "",
"createnewcustomer": "",
"findmakemodelcode": "",
"getmakes": "",
@@ -1632,6 +1640,7 @@
},
"post": "",
"refetchmakesmodels": "",
"update_ro": "",
"usegeneric": "",
"useselected": ""
},
@@ -1700,8 +1709,8 @@
"actual_in": "Real en",
"acv_amount": "",
"adjustment_bottom_line": "Ajustes",
"admin_clerk": "",
"adjustmenthours": "",
"admin_clerk": "",
"alt_transport": "",
"area_of_damage_impact": {
"10": "",
@@ -1782,9 +1791,8 @@
"ded_status": "Estado deducible",
"depreciation_taxes": "Depreciación / Impuestos",
"dms": {
"first_name": "",
"last_name": "",
"address": "",
"advisor": "",
"amount": "",
"center": "",
"control_type": {
@@ -1792,29 +1800,36 @@
},
"cost": "",
"cost_dms_acctnumber": "",
"customer": "",
"dms_make": "",
"dms_model": "",
"dms_model_override": "",
"make_override": "",
"advisor": "",
"dms_unsold": "",
"dms_wip_acctnumber": "",
"first_name": "",
"id": "",
"inservicedate": "",
"journal": "",
"last_name": "",
"lines": "",
"make_override": "",
"name1": "",
"payer": {
"amount": "",
"control_type": "",
"controlnumber": "",
"dms_acctnumber": "",
"name": ""
"name": "",
"payer_type": ""
},
"sale": "",
"sale_dms_acctnumber": "",
"story": "",
"vinowner": ""
"vinowner": "",
"rr_opcode": "",
"rr_opcode_prefix": "",
"rr_opcode_suffix": "",
"rr_opcode_base": ""
},
"dms_allocation": "",
"driveable": "",
@@ -2099,6 +2114,11 @@
"damageto": "",
"defaultstory": "",
"disablebillwip": "",
"earlyro": {
"created": "",
"fields": "",
"willupdate": ""
},
"invoicedatefuture": "",
"kmoutnotgreaterthankmin": "",
"logs": "",
@@ -2256,6 +2276,7 @@
"delete": "",
"deleted": "Trabajo eliminado con éxito.",
"duplicated": "",
"early_ro_created": "",
"exported": "",
"invoiced": "",
"ioucreated": "",
@@ -2444,6 +2465,7 @@
"labels": {
"addlabel": "",
"archive": "",
"mark_unread": "",
"maxtenimages": "",
"messaging": "Mensajería",
"no_consent": "",
@@ -2456,8 +2478,7 @@
"selectmedia": "",
"sentby": "",
"typeamessage": "Enviar un mensaje...",
"unarchive": "",
"mark_unread": ""
"unarchive": ""
},
"render": {
"conversation_list": ""
@@ -2611,20 +2632,20 @@
"name": ""
},
"labels": {
"cell": "",
"create_new": "Crea un nuevo registro de propietario.",
"deleteconfirm": "",
"email": "",
"existing_owners": "Propietarios existentes",
"fromclaim": "",
"fromowner": "",
"relatedjobs": "",
"updateowner": "",
"work": "",
"home": "",
"cell": "",
"other": "",
"email": "",
"phone": "",
"sms": ""
"relatedjobs": "",
"sms": "",
"updateowner": "",
"work": ""
},
"successes": {
"delete": "",
@@ -2635,6 +2656,10 @@
"actions": {
"order": "Pedido de piezas",
"orderinhouse": ""
},
"labels": {
"view_counts_only": "",
"view_timestamps": ""
}
},
"parts_dispatch": {
@@ -2984,8 +3009,6 @@
"settings": ""
},
"labels": {
"click_for_statuses": "",
"partsreceived": "",
"actual_in": "",
"addnewprofile": "",
"alert": "",
@@ -3004,6 +3027,7 @@
"card_size": "",
"cardcolor": "",
"cardsettings": "",
"click_for_statuses": "",
"clm_no": "",
"comment": "",
"compact": "",
@@ -3024,6 +3048,7 @@
"orientation": "",
"ownr_nm": "",
"paintpriority": "",
"partsreceived": "",
"partsstatus": "",
"production_note": "",
"refinishhours": "",
@@ -3570,18 +3595,12 @@
}
},
"titles": {
"simplified-parts-jobs": "",
"parts": "",
"parts_settings": "",
"accounting-payables": "",
"accounting-payments": "",
"accounting-receivables": "",
"all_tasks": "",
"app": "",
"bc": {
"simplified-parts-jobs": "",
"parts": "",
"parts_settings": "",
"accounting-payables": "",
"accounting-payments": "",
"accounting-receivables": "",
@@ -3613,7 +3632,9 @@
"my_tasks": "",
"owner-detail": "",
"owners": "",
"parts": "",
"parts-queue": "",
"parts_settings": "",
"payments-all": "",
"phonebook": "",
"productionboard": "",
@@ -3625,6 +3646,7 @@
"shop-csi": "",
"shop-templates": "",
"shop-vendors": "",
"simplified-parts-jobs": "",
"tasks": "",
"temporarydocs": "",
"timetickets": "",
@@ -3660,7 +3682,9 @@
"my_tasks": "",
"owners": "Todos los propietarios | {{app}}",
"owners-detail": "",
"parts": "",
"parts-queue": "",
"parts_settings": "",
"payments-all": "",
"phonebook": "",
"productionboard": "",
@@ -3676,6 +3700,7 @@
"shop-csi": "",
"shop-templates": "",
"shop_vendors": "Vendedores | {{app}}",
"simplified-parts-jobs": "",
"tasks": "",
"techconsole": "{{app}}",
"techjobclock": "{{app}}",
@@ -3836,10 +3861,10 @@
"user": {
"actions": {
"changepassword": "",
"signout": "desconectar",
"updateprofile": "Actualización del perfil",
"dark_theme": "",
"light_theme": "",
"dark_theme": ""
"signout": "desconectar",
"updateprofile": "Actualización del perfil"
},
"errors": {
"updating": ""
@@ -3853,14 +3878,14 @@
"labels": {
"actions": "",
"changepassword": "",
"profileinfo": "",
"user_settings": "",
"play_sound_for_new_messages": "",
"notification_sound_on": "",
"notification_sound_off": "",
"notification_sound_enabled": "",
"notification_sound_disabled": "",
"notification_sound_help": ""
"notification_sound_enabled": "",
"notification_sound_help": "",
"notification_sound_off": "",
"notification_sound_on": "",
"play_sound_for_new_messages": "",
"profileinfo": "",
"user_settings": ""
},
"successess": {
"passwordchanged": ""

View File

@@ -48,6 +48,7 @@
"arrivedon": "Arrivé le:",
"arrivingjobs": "",
"blocked": "",
"bp": "",
"cancelledappointment": "Rendez-vous annulé pour:",
"completingjobs": "",
"dataconsistency": "",
@@ -59,18 +60,17 @@
"noarrivingjobs": "",
"nocompletingjobs": "",
"nodateselected": "Aucune date n'a été sélectionnée.",
"owner": "",
"priorappointments": "Rendez-vous précédents",
"reminder": "",
"ro_number": "",
"scheduled_completion": "",
"scheduledfor": "Rendez-vous prévu pour:",
"severalerrorsfound": "",
"smartscheduling": "",
"smspaymentreminder": "",
"suggesteddates": "",
"ro_number": "",
"owner": "",
"vehicle": "",
"bp": "",
"scheduled_completion": ""
"vehicle": ""
},
"successes": {
"canceled": "Rendez-vous annulé avec succès.",
@@ -90,6 +90,11 @@
"actions": "actes"
}
},
"audio": {
"manager": {
"description": ""
}
},
"audit": {
"fields": {
"cc": "",
@@ -149,11 +154,6 @@
"tasks_updated": ""
}
},
"audio": {
"manager": {
"description": ""
}
},
"billlines": {
"actions": {
"newline": ""
@@ -281,9 +281,9 @@
},
"errors": {
"creatingdefaultview": "",
"duplicate_insurance_company": "",
"loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.",
"saving": "",
"duplicate_insurance_company": ""
"saving": ""
},
"fields": {
"ReceivableCustomField": "",
@@ -564,21 +564,18 @@
"responsibilitycenter_tax_tier": "",
"responsibilitycenter_tax_type": "",
"responsibilitycenters": {
"gogcode": "",
"item_type": "Item Type",
"item_type_gog": "",
"item_type_paint": "",
"item_type_freight": "",
"taxable_flag": "",
"taxable": "",
"nontaxable": "",
"ap": "",
"ar": "",
"ats": "",
"federal_tax": "",
"federal_tax_itc": "",
"gogcode": "",
"gst_override": "",
"invoiceexemptcode": "",
"item_type": "Item Type",
"item_type_freight": "",
"item_type_gog": "",
"item_type_paint": "",
"itemexemptcode": "",
"la1": "",
"la2": "",
@@ -597,6 +594,7 @@
"local_tax": "",
"mapa": "",
"mash": "",
"nontaxable": "",
"paa": "",
"pac": "",
"pag": "",
@@ -617,6 +615,8 @@
"state": ""
},
"state_tax": "",
"taxable": "",
"taxable_flag": "",
"tow": ""
},
"schedule_end_time": "",
@@ -678,8 +678,6 @@
"zip_post": ""
},
"labels": {
"parts_shop_management": "",
"parts_vendor_management": "",
"2tiername": "",
"2tiersetup": "",
"2tiersource": "",
@@ -702,11 +700,11 @@
"payers": ""
},
"cdk_dealerid": "",
"rr_dealerid": "",
"costsmapping": "",
"dms_allocations": "",
"pbs_serialnumber": "",
"profitsmapping": "",
"rr_dealerid": "",
"title": ""
},
"emaillater": "",
@@ -733,6 +731,8 @@
"followers": ""
},
"orderstatuses": "",
"parts_shop_management": "",
"parts_vendor_management": "",
"partslocations": "",
"partsscan": "",
"printlater": "",
@@ -1047,7 +1047,9 @@
},
"dms": {
"errors": {
"alreadyexported": ""
"alreadyexported": "",
"earlyrorequired": "",
"earlyrorequired.message": ""
},
"labels": {
"refreshallocations": ""
@@ -1244,9 +1246,11 @@
"deselectall": "",
"download": "",
"edit": "modifier",
"gotoadmin": "",
"login": "",
"next": "",
"ok": "",
"optional": "",
"previous": "",
"print": "",
"refresh": "",
@@ -1257,6 +1261,7 @@
"save": "sauvegarder",
"saveandnew": "",
"saveas": "",
"select": "",
"selectall": "",
"send": "",
"sendbysms": "",
@@ -1286,8 +1291,7 @@
"vehicle": ""
},
"labels": {
"selected": "",
"settings": "",
"apply": "",
"actions": "actes",
"areyousure": "",
"barcode": "code à barre",
@@ -1341,8 +1345,10 @@
"search": "Chercher...",
"searchresults": "",
"selectdate": "",
"selected": "",
"sendagain": "",
"sendby": "",
"settings": "",
"signin": "",
"sms": "",
"status": "",
@@ -1585,13 +1591,13 @@
"labels": {
"adjustmenttobeadded": "",
"billref": "",
"bulk_location_help": "",
"convertedtolabor": "",
"edit": "Ligne d'édition",
"ioucreated": "",
"new": "Nouvelle ligne",
"nostatus": "",
"presets": "",
"bulk_location_help": ""
"presets": ""
},
"successes": {
"created": "",
@@ -1619,11 +1625,13 @@
"changestatus": "Changer le statut",
"changestimator": "",
"convert": "Convertir",
"convertwithoutearlyro": "",
"createiou": "",
"deliver": "",
"deliver_quick": "",
"dms": {
"addpayer": "",
"createearlyro": "",
"createnewcustomer": "",
"findmakemodelcode": "",
"getmakes": "",
@@ -1632,6 +1640,7 @@
},
"post": "",
"refetchmakesmodels": "",
"update_ro": "",
"usegeneric": "",
"useselected": ""
},
@@ -1699,9 +1708,9 @@
"actual_delivery": "Livraison réelle",
"actual_in": "En réel",
"acv_amount": "",
"admin_clerk": "",
"adjustment_bottom_line": "Ajustements",
"adjustmenthours": "",
"admin_clerk": "",
"alt_transport": "",
"area_of_damage_impact": {
"10": "",
@@ -1782,9 +1791,8 @@
"ded_status": "Statut de franchise",
"depreciation_taxes": "Amortissement / taxes",
"dms": {
"first_name": "",
"last_name": "",
"address": "",
"advisor": "",
"amount": "",
"center": "",
"control_type": {
@@ -1792,29 +1800,36 @@
},
"cost": "",
"cost_dms_acctnumber": "",
"customer": "",
"dms_make": "",
"dms_model": "",
"dms_model_override": "",
"make_override": "",
"advisor": "",
"dms_unsold": "",
"dms_wip_acctnumber": "",
"first_name": "",
"id": "",
"inservicedate": "",
"journal": "",
"last_name": "",
"lines": "",
"make_override": "",
"name1": "",
"payer": {
"amount": "",
"control_type": "",
"controlnumber": "",
"dms_acctnumber": "",
"name": ""
"name": "",
"payer_type": ""
},
"sale": "",
"sale_dms_acctnumber": "",
"story": "",
"vinowner": ""
"vinowner": "",
"rr_opcode": "",
"rr_opcode_prefix": "",
"rr_opcode_suffix": "",
"rr_opcode_base": ""
},
"dms_allocation": "",
"driveable": "",
@@ -2099,6 +2114,11 @@
"damageto": "",
"defaultstory": "",
"disablebillwip": "",
"earlyro": {
"created": "",
"fields": "",
"willupdate": ""
},
"invoicedatefuture": "",
"kmoutnotgreaterthankmin": "",
"logs": "",
@@ -2256,6 +2276,7 @@
"delete": "",
"deleted": "Le travail a bien été supprimé.",
"duplicated": "",
"early_ro_created": "",
"exported": "",
"invoiced": "",
"ioucreated": "",
@@ -2433,7 +2454,6 @@
"actions": {
"link": "",
"new": "",
"openchat": ""
},
"errors": {
@@ -2445,6 +2465,7 @@
"labels": {
"addlabel": "",
"archive": "",
"mark_unread": "",
"maxtenimages": "",
"messaging": "Messagerie",
"no_consent": "",
@@ -2457,8 +2478,7 @@
"selectmedia": "",
"sentby": "",
"typeamessage": "Envoyer un message...",
"unarchive": "",
"mark_unread": ""
"unarchive": ""
},
"render": {
"conversation_list": ""
@@ -2612,20 +2632,20 @@
"name": ""
},
"labels": {
"cell": "",
"create_new": "Créez un nouvel enregistrement de propriétaire.",
"deleteconfirm": "",
"email": "",
"existing_owners": "Propriétaires existants",
"fromclaim": "",
"fromowner": "",
"relatedjobs": "",
"updateowner": "",
"work": "",
"home": "",
"cell": "",
"other": "",
"email": "",
"phone": "",
"sms": ""
"relatedjobs": "",
"sms": "",
"updateowner": "",
"work": ""
},
"successes": {
"delete": "",
@@ -2636,6 +2656,10 @@
"actions": {
"order": "Commander des pièces",
"orderinhouse": ""
},
"labels": {
"view_counts_only": "",
"view_timestamps": ""
}
},
"parts_dispatch": {
@@ -2985,8 +3009,6 @@
"settings": ""
},
"labels": {
"click_for_statuses": "",
"partsreceived": "",
"actual_in": "",
"addnewprofile": "",
"alert": "",
@@ -3005,6 +3027,7 @@
"card_size": "",
"cardcolor": "",
"cardsettings": "",
"click_for_statuses": "",
"clm_no": "",
"comment": "",
"compact": "",
@@ -3025,6 +3048,7 @@
"orientation": "",
"ownr_nm": "",
"paintpriority": "",
"partsreceived": "",
"partsstatus": "",
"production_note": "",
"refinishhours": "",
@@ -3571,18 +3595,12 @@
}
},
"titles": {
"simplified-parts-jobs": "",
"parts": "",
"parts_settings": "",
"accounting-payables": "",
"accounting-payments": "",
"accounting-receivables": "",
"all_tasks": "",
"app": "",
"bc": {
"simplified-parts-jobs": "",
"parts": "",
"parts_settings": "",
"accounting-payables": "",
"accounting-payments": "",
"accounting-receivables": "",
@@ -3614,7 +3632,9 @@
"my_tasks": "",
"owner-detail": "",
"owners": "",
"parts": "",
"parts-queue": "",
"parts_settings": "",
"payments-all": "",
"phonebook": "",
"productionboard": "",
@@ -3626,6 +3646,7 @@
"shop-csi": "",
"shop-templates": "",
"shop-vendors": "",
"simplified-parts-jobs": "",
"tasks": "",
"temporarydocs": "",
"timetickets": "",
@@ -3661,7 +3682,9 @@
"my_tasks": "",
"owners": "Tous les propriétaires | {{app}}",
"owners-detail": "",
"parts": "",
"parts-queue": "",
"parts_settings": "",
"payments-all": "",
"phonebook": "",
"productionboard": "",
@@ -3677,6 +3700,7 @@
"shop-csi": "",
"shop-templates": "",
"shop_vendors": "Vendeurs | {{app}}",
"simplified-parts-jobs": "",
"tasks": "",
"techconsole": "{{app}}",
"techjobclock": "{{app}}",
@@ -3837,10 +3861,10 @@
"user": {
"actions": {
"changepassword": "",
"signout": "Déconnexion",
"updateprofile": "Mettre à jour le profil",
"dark_theme": "",
"light_theme": "",
"dark_theme": ""
"signout": "Déconnexion",
"updateprofile": "Mettre à jour le profil"
},
"errors": {
"updating": ""
@@ -3854,14 +3878,14 @@
"labels": {
"actions": "",
"changepassword": "",
"profileinfo": "",
"user_settings": "",
"play_sound_for_new_messages": "",
"notification_sound_on": "",
"notification_sound_off": "",
"notification_sound_enabled": "",
"notification_sound_disabled": "",
"notification_sound_help": ""
"notification_sound_enabled": "",
"notification_sound_help": "",
"notification_sound_off": "",
"notification_sound_on": "",
"play_sound_for_new_messages": "",
"profileinfo": "",
"user_settings": ""
},
"successess": {
"passwordchanged": ""

View File

@@ -5,8 +5,10 @@ export function DateFormatter(props) {
return props.children ? dayjs(props.children).format(props.includeDay ? "ddd MM/DD/YYYY" : "MM/DD/YYYY") : null;
}
export function DateTimeFormatter(props) {
return props.children ? dayjs(props.children).format(props.format ? props.format : "MM/DD/YYYY hh:mm a") : null;
export function DateTimeFormatter({ hideTime, ...props }) {
return props.children
? dayjs(props.children).format(props.format ? props.format : `MM/DD/YYYY${hideTime ? "" : " hh:mm a"}`)
: null;
}
export function DateTimeFormatterFunction(date) {
@@ -17,11 +19,11 @@ export function TimeFormatter(props) {
return props.children ? dayjs(props.children).format(props.format ? props.format : "hh:mm a") : null;
}
export function TimeAgoFormatter(props) {
export function TimeAgoFormatter({ removeAgoString = false, ...props }) {
const m = dayjs(props.children);
return props.children ? (
<Tooltip placement="top" title={m.format("MM/DD/YYY hh:mm A")}>
{m.fromNow()}
<Tooltip placement="top" title={m.format("MM/DD/YYYY hh:mm A")}>
{m.fromNow(removeAgoString)}
</Tooltip>
) : null;
}

View File

@@ -146,7 +146,8 @@ export async function generateTemplate(
if (templateQueryToExecute) {
const { data } = await client.query({
query: gql(finalQuery),
variables: { ...templateObject.variables }
variables: { ...templateObject.variables },
fetchPolicy: "no-cache"
});
contextData = data;
}

View File

@@ -38,8 +38,6 @@ services:
condition: service_healthy
localstack:
condition: service_healthy
aws-cli:
condition: service_completed_successfully
ports:
- "4001:4000" # Different external port for local access
volumes:
@@ -65,8 +63,6 @@ services:
condition: service_healthy
localstack:
condition: service_healthy
aws-cli:
condition: service_completed_successfully
ports:
- "4002:4000" # Different external port for local access
volumes:
@@ -92,8 +88,6 @@ services:
condition: service_healthy
localstack:
condition: service_healthy
aws-cli:
condition: service_completed_successfully
ports:
- "4003:4000" # Different external port for local access
volumes:
@@ -156,23 +150,18 @@ services:
# LocalStack
localstack:
image: localstack/localstack
image: localstack/localstack:4.13.1
container_name: localstack
hostname: localstack
networks:
- redis-cluster-net
restart: unless-stopped
volumes:
- ./certs:/tmp/certs:ro # only if your script reads /tmp/certs/...
- ./localstack/init:/etc/localstack/init/ready.d:ro
- /var/run/docker.sock:/var/run/docker.sock
environment:
- SERVICES=s3,ses,secretsmanager,cloudwatch,logs
- DEBUG=0
- AWS_ACCESS_KEY_ID=test
- AWS_SECRET_ACCESS_KEY=test
- AWS_DEFAULT_REGION=ca-central-1
- EXTRA_CORS_ALLOWED_HEADERS=Authorization,Content-Type
- EXTRA_CORS_ALLOWED_ORIGINS=*
- EXTRA_CORS_EXPOSE_HEADERS=Authorization,Content-Type
env_file:
- .env.localstack.docker
ports:
- "4566:4566"
healthcheck:
@@ -182,36 +171,6 @@ services:
retries: 5
start_period: 20s
# AWS-CLI
aws-cli:
image: amazon/aws-cli
container_name: aws-cli
hostname: aws-cli
networks:
- redis-cluster-net
depends_on:
localstack:
condition: service_healthy
volumes:
- './localstack:/tmp/localstack'
- './certs:/tmp/certs'
environment:
- AWS_ACCESS_KEY_ID=test
- AWS_SECRET_ACCESS_KEY=test
- AWS_DEFAULT_REGION=ca-central-1
entrypoint: /bin/sh -c
command: >
"
aws --endpoint-url=http://localstack:4566 ses verify-domain-identity --domain imex.online --region ca-central-1
aws --endpoint-url=http://localstack:4566 ses verify-email-identity --email-address noreply@imex.online --region ca-central-1
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rome-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rps-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
"
networks:
redis-cluster-net:
driver: bridge

View File

@@ -68,23 +68,18 @@ services:
# LocalStack: Used to emulate AWS services locally, currently setup for SES
# Notes: Set the ENV Debug to 1 for additional logging
localstack:
image: localstack/localstack
image: localstack/localstack:4.13.1
container_name: localstack
hostname: localstack
networks:
- redis-cluster-net
restart: unless-stopped
volumes:
- ./certs:/tmp/certs:ro # only if your script reads /tmp/certs/...
- ./localstack/init:/etc/localstack/init/ready.d:ro
- /var/run/docker.sock:/var/run/docker.sock
environment:
- SERVICES=s3,ses,secretsmanager,cloudwatch,logs
- DEBUG=0
- AWS_ACCESS_KEY_ID=test
- AWS_SECRET_ACCESS_KEY=test
- AWS_DEFAULT_REGION=ca-central-1
- EXTRA_CORS_ALLOWED_HEADERS=Authorization,Content-Type
- EXTRA_CORS_ALLOWED_ORIGINS=*
- EXTRA_CORS_EXPOSE_HEADERS=Authorization,Content-Type
env_file:
- .env.localstack.docker
ports:
- "4566:4566"
healthcheck:
@@ -94,38 +89,6 @@ services:
retries: 5
start_period: 20s
# AWS-CLI - Used in conjunction with LocalStack to set required permission to send emails
aws-cli:
image: amazon/aws-cli
container_name: aws-cli
hostname: aws-cli
networks:
- redis-cluster-net
depends_on:
localstack:
condition: service_healthy
volumes:
- './localstack:/tmp/localstack'
- './certs:/tmp/certs'
environment:
- AWS_ACCESS_KEY_ID=test
- AWS_SECRET_ACCESS_KEY=test
- AWS_DEFAULT_REGION=ca-central-1
entrypoint: /bin/sh -c
command: >
"
aws --endpoint-url=http://localstack:4566 ses verify-domain-identity --domain imex.online --region ca-central-1
aws --endpoint-url=http://localstack:4566 ses verify-email-identity --email-address noreply@imex.online --region ca-central-1
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-job-totals --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket parts-estimates --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rome-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rps-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
"
# Node App: The Main IMEX API
node-app:
build:
@@ -145,8 +108,7 @@ services:
condition: service_healthy
localstack:
condition: service_healthy
aws-cli:
condition: service_completed_successfully
ports:
- "4000:4000"
- "9229:9229"

View File

@@ -947,6 +947,7 @@
- carfax_exclude
- cdk_configuration
- cdk_dealerid
- chatter_company_id
- chatterid
- city
- claimscorpid
@@ -1063,6 +1064,7 @@
- bill_allow_post_to_closed
- bill_tax_rates
- cdk_configuration
- chatter_company_id
- city
- country
- created_at
@@ -3702,7 +3704,9 @@
- ded_status
- deliverchecklist
- depreciation_taxes
- dms_advisor_id
- dms_allocation
- dms_customer_id
- dms_id
- driveable
- employee_body
@@ -3983,7 +3987,9 @@
- ded_status
- deliverchecklist
- depreciation_taxes
- dms_advisor_id
- dms_allocation
- dms_customer_id
- dms_id
- driveable
- employee_body
@@ -4276,7 +4282,9 @@
- ded_status
- deliverchecklist
- depreciation_taxes
- dms_advisor_id
- dms_allocation
- dms_customer_id
- dms_id
- driveable
- employee_body

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 "chatter_company_id" text
-- null;

View File

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

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"."jobs" add column "dms_customer_id" text
-- null;

View File

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

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"."jobs" add column "dms_advisor_id" text
-- null;

View File

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

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
echo "Running LocalStack bootstrap script: 10-bootstrap.sh"
set -euo pipefail
REGION="${AWS_DEFAULT_REGION:-ca-central-1}"
# awslocal is the LocalStack wrapper so you don't need --endpoint-url
# (it targets the LocalStack gateway automatically)
# Docs: https://docs.localstack.cloud/.../aws-cli/
ensure_bucket() {
local b="$1"
if ! awslocal s3api head-bucket --bucket "$b" >/dev/null 2>&1; then
awslocal s3api create-bucket \
--bucket "$b" \
--create-bucket-configuration LocationConstraint="$REGION" \
--region "$REGION" >/dev/null
fi
}
ensure_log_group() {
local lg="$1"
awslocal logs create-log-group --log-group-name "$lg" --region "$REGION" >/dev/null 2>&1 || true
}
ensure_secret_string() {
local name="$1"
local value="$2"
if awslocal secretsmanager describe-secret --secret-id "$name" >/dev/null 2>&1; then
awslocal secretsmanager update-secret --secret-id "$name" --secret-string "$value" >/dev/null
else
awslocal secretsmanager create-secret --name "$name" --secret-string "$value" >/dev/null
fi
}
ensure_secret_file() {
local name="$1"
local filepath="$2"
if awslocal secretsmanager describe-secret --secret-id "$name" >/dev/null 2>&1; then
awslocal secretsmanager update-secret --secret-id "$name" --secret-string "file://$filepath" >/dev/null
else
awslocal secretsmanager create-secret --name "$name" --secret-string "file://$filepath" >/dev/null
fi
}
# SES identities (idempotent-ish; ignoring if it already exists)
awslocal ses verify-domain-identity --domain imex.online --region "$REGION" >/dev/null || true
awslocal ses verify-email-identity --email-address noreply@imex.online --region "$REGION" >/dev/null || true
# Secrets
ensure_secret_file "CHATTER_PRIVATE_KEY" "/tmp/certs/io-ftp-test.key"
ensure_secret_string "CHATTER_COMPANY_KEY_6713" "${CHATTER_COMPANY_KEY_6713:-REPLACE_ME}"
# Logs
ensure_log_group "development"
# Buckets
ensure_bucket "imex-job-totals"
ensure_bucket "parts-estimate"
ensure_bucket "imex-large-log"
ensure_bucket "imex-carfax-uploads"
ensure_bucket "rome-carfax-uploads"
ensure_bucket "rps-carfax-uploads"

View File

@@ -40,6 +40,8 @@ const { loadEmailQueue } = require("./server/notifications/queues/emailQueue");
const { loadAppQueue } = require("./server/notifications/queues/appQueue");
const { SetLegacyWebsocketHandlers } = require("./server/web-sockets/web-socket");
const { loadFcmQueue } = require("./server/notifications/queues/fcmQueue");
const { loadChatterApiQueue } = require("./server/data/queues/chatterApiQueue");
const { processChatterApiJob } = require("./server/data/chatter-api");
const CLUSTER_RETRY_BASE_DELAY = 100;
const CLUSTER_RETRY_MAX_DELAY = 5000;
@@ -125,6 +127,7 @@ const applyRoutes = ({ app }) => {
app.use("/payroll", require("./server/routes/payrollRoutes"));
app.use("/sso", require("./server/routes/ssoRoutes"));
app.use("/integrations", require("./server/routes/intergrationRoutes"));
app.use("/chatter", require("./server/routes/chatterRoutes"));
// Default route for forbidden access
app.get("/", (req, res) => {
@@ -390,6 +393,15 @@ const applySocketIO = async ({ server, app }) => {
const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
const queueSettings = { pubClient, logger, redisHelpers, ioRedis };
// Load chatterApi queue with processJob function and redis helpers
const chatterApiQueue = await loadChatterApiQueue({
pubClient,
logger,
processJob: processChatterApiJob,
getChatterToken: redisHelpers.getChatterToken,
setChatterToken: redisHelpers.setChatterToken
});
// Assuming loadEmailQueue and loadAppQueue return Promises
const [notificationsEmailsQueue, notificationsAppQueue, notificationsFcmQueue] = await Promise.all([
loadEmailQueue(queueSettings),
@@ -409,6 +421,10 @@ const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
notificationsFcmQueue.on("error", (error) => {
logger.log(`Error in notificationsFCMQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
});
chatterApiQueue.on("error", (error) => {
logger.log(`Error in chatterApiQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
});
};
/**

View File

@@ -0,0 +1,139 @@
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
const { defaultProvider } = require("@aws-sdk/credential-provider-node");
const { isString, isEmpty } = require("lodash");
const CHATTER_BASE_URL = process.env.CHATTER_API_BASE_URL || "https://api.chatterresearch.com";
const AWS_REGION = process.env.AWS_REGION || "ca-central-1";
// Configure SecretsManager client with localstack support
const secretsClientOptions = {
region: AWS_REGION,
credentials: defaultProvider()
};
const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME);
if (isLocal) {
secretsClientOptions.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`;
}
const secretsClient = new SecretsManagerClient(secretsClientOptions);
/**
* Chatter API Client for making requests to the Chatter API
*/
class ChatterApiClient {
constructor({ baseUrl, apiToken }) {
if (!apiToken) throw new Error("ChatterApiClient requires apiToken");
this.baseUrl = String(baseUrl || "").replace(/\/+$/, "");
this.apiToken = apiToken;
}
async createLocation(companyId, payload) {
return this.request(`/api/v1/companies/${companyId}/locations`, {
method: "POST",
body: payload
});
}
async postInteraction(companyId, payload) {
return this.request(`/api/v1/companies/${companyId}/solicitation/interaction`, {
method: "POST",
body: payload
});
}
async request(path, { method = "GET", body } = {}) {
const res = await fetch(this.baseUrl + path, {
method,
headers: {
"Api-Token": this.apiToken,
Accept: "application/json",
...(body ? { "Content-Type": "application/json" } : {})
},
body: body ? JSON.stringify(body) : undefined
});
const text = await res.text();
const data = text ? safeJson(text) : null;
if (!res.ok) {
const err = new Error(`Chatter API error ${res.status} | ${data?.message}`);
err.status = res.status;
err.data = data;
const retryAfterMs = parseRetryAfterMs(res.headers.get("retry-after"));
if (retryAfterMs != null) err.retryAfterMs = retryAfterMs;
throw err;
}
return data;
}
}
/**
* Safely parse JSON, returning original text if parsing fails
*/
function safeJson(text) {
try {
return JSON.parse(text);
} catch {
return text;
}
}
function parseRetryAfterMs(value) {
if (!value) return null;
const sec = Number(value);
if (Number.isFinite(sec) && sec >= 0) return Math.ceil(sec * 1000);
const dateMs = Date.parse(value);
if (!Number.isFinite(dateMs)) return null;
return Math.max(0, dateMs - Date.now());
}
/**
* Fetches Chatter API token from AWS Secrets Manager
* SecretId format: CHATTER_COMPANY_KEY_<companyId>
*
* @param {string|number} companyId - The company ID
* @returns {Promise<string>} The API token
*/
async function getChatterApiToken(companyId) {
const key = String(companyId ?? "").trim();
if (!key) throw new Error("getChatterApiToken: companyId is required");
// Optional override for development/testing
if (process.env.CHATTER_API_TOKEN) return process.env.CHATTER_API_TOKEN;
const secretId = `CHATTER_COMPANY_KEY_${key}`;
const command = new GetSecretValueCommand({ SecretId: secretId });
const { SecretString, SecretBinary } = await secretsClient.send(command);
const token =
(SecretString && SecretString.trim()) ||
(SecretBinary && Buffer.from(SecretBinary, "base64").toString("ascii").trim()) ||
"";
if (!token) throw new Error(`Chatter API token secret is empty: ${secretId}`);
return token;
}
/**
* Creates a Chatter API client instance
*
* @param {string|number} companyId - The company ID
* @param {string} [baseUrl] - Optional base URL override
* @returns {Promise<ChatterApiClient>} Configured API client
*/
async function createChatterClient(companyId, baseUrl = CHATTER_BASE_URL) {
const apiToken = await getChatterApiToken(companyId);
return new ChatterApiClient({ baseUrl, apiToken });
}
module.exports = {
ChatterApiClient,
getChatterApiToken,
createChatterClient,
safeJson,
CHATTER_BASE_URL
};

View File

@@ -0,0 +1,123 @@
const DEFAULT_COMPANY_ID = process.env.CHATTER_DEFAULT_COMPANY_ID;
const client = require("../graphql-client/graphql-client").client;
const { createChatterClient } = require("./chatter-client");
const InstanceManager = require("../utils/instanceMgr").default;
const GET_BODYSHOP_FOR_CHATTER = `
query GET_BODYSHOP_FOR_CHATTER($id: uuid!) {
bodyshops_by_pk(id: $id) {
id
shopname
address1
city
state
zip_post
imexshopid
chatterid
chatter_company_id
}
}
`;
const UPDATE_BODYSHOP_CHATTER_FIELDS = `
mutation UPDATE_BODYSHOP_CHATTER_FIELDS($id: uuid!, $chatter_company_id: String!, $chatterid: String!) {
update_bodyshops_by_pk(pk_columns: {id: $id}, _set: {chatter_company_id: $chatter_company_id, chatterid: $chatterid}) {
id
chatter_company_id
chatterid
}
}
`;
const createLocation = async (req, res) => {
const { logger } = req;
const { bodyshopID, googlePlaceID } = req.body;
console.dir({ body: req.body });
if (!DEFAULT_COMPANY_ID) {
logger.log("chatter-create-location-no-default-company", "warn", null, null, { bodyshopID });
return res.json({ success: false, message: "No default company set" });
}
if (!googlePlaceID) {
logger.log("chatter-create-location-no-google-place-id", "warn", null, null, { bodyshopID });
return res.json({ success: false, message: "No google place id provided" });
}
if (!bodyshopID) {
logger.log("chatter-create-location-invalid-bodyshop", "warn", null, null, { bodyshopID });
return res.json({ success: false, message: "No bodyshop id" });
}
try {
const { bodyshops_by_pk: bodyshop } = await client.request(GET_BODYSHOP_FOR_CHATTER, { id: bodyshopID });
if (!bodyshop) {
logger.log("chatter-create-location-bodyshop-not-found", "warn", null, null, { bodyshopID });
return res.json({ success: false, message: "Bodyshop not found" });
}
if (bodyshop.chatter_company_id && bodyshop.chatterid) {
logger.log("chatter-create-location-already-exists", "warn", null, null, {
bodyshopID
});
return res.json({ success: false, message: "This Bodyshop already has a location associated with it" });
}
const chatterApi = await createChatterClient(DEFAULT_COMPANY_ID);
const locationIdentifier = `${DEFAULT_COMPANY_ID}-${bodyshop.id}`;
const locationPayload = {
name: bodyshop.shopname,
locationIdentifier: locationIdentifier,
address: bodyshop.address1,
postalCode: bodyshop.zip_post,
state: bodyshop.state,
city: bodyshop.city,
country: InstanceManager({ imex: "Canada", rome: "US" }),
googlePlaceId: googlePlaceID,
status: "active"
};
logger.log("chatter-create-location-calling-api", "info", null, null, { bodyshopID, locationIdentifier });
const response = await chatterApi.createLocation(DEFAULT_COMPANY_ID, locationPayload);
if (!response.location?.id) {
logger.log("chatter-create-location-no-location-id", "error", null, null, { bodyshopID, response });
return res.json({ success: false, message: "No location ID in response", data: response });
}
await client.request(UPDATE_BODYSHOP_CHATTER_FIELDS, {
id: bodyshopID,
chatter_company_id: DEFAULT_COMPANY_ID,
chatterid: String(response.location.id)
});
logger.log("chatter-create-location-success", "info", null, null, {
bodyshopID,
chatter_company_id: DEFAULT_COMPANY_ID,
chatterid: response.location.id,
locationIdentifier
});
return res.json({ success: true, data: response });
} catch (error) {
logger.log("chatter-create-location-error", "error", null, null, {
bodyshopID,
error: error.message,
status: error.status,
data: error.data
});
return res.json({
success: false,
message: error.message || "Failed to create location",
error: error.data
});
}
};
module.exports = createLocation;

View File

@@ -221,6 +221,8 @@ const CreateRepairOrderTag = (job, errorCallback) => {
const repairCosts = CreateCosts(job);
const LaborDetailLines = generateLaborLines(job.timetickets);
//Calculate detail only lines.
const detailAdjustments = job.joblines
.filter((jl) => jl.ah_detail_line && jl.mod_lbr_ty)
@@ -606,12 +608,14 @@ const CreateRepairOrderTag = (job, errorCallback) => {
// CSIID: null,
InsGroupCode: null
},
DetailLines: {
DetailLine:
job.joblines.length > 0
? job.joblines.map((jl) => GenerateDetailLines(job, jl, job.bodyshop.md_order_statuses))
: [generateNullDetailLine()]
},
LaborDetailLines: {
LaborDetailLine: LaborDetailLines
}
};
return ret;
@@ -787,6 +791,76 @@ const CreateCosts = (job) => {
};
};
const generateLaborLines = (timetickets) => {
if (!timetickets || timetickets.length === 0) return [];
const codeToProps = {
LAB: { actual: "LaborBodyActualHours", flag: "LaborBodyFlagHours", cost: "LaborBodyCost" },
LAM: { actual: "LaborMechanicalActualHours", flag: "LaborMechanicalFlagHours", cost: "LaborMechanicalCost" },
LAG: { actual: "LaborGlassActualHours", flag: "LaborGlassFlagHours", cost: "LaborGlassCost" },
LAS: { actual: "LaborStructuralActualHours", flag: "LaborStructuralFlagHours", cost: "LaborStructuralCost" },
LAE: { actual: "LaborElectricalActualHours", flag: "LaborElectricalFlagHours", cost: "LaborElectricalCost" },
LAA: { actual: "LaborAluminumActualHours", flag: "LaborAluminumFlagHours", cost: "LaborAluminumCost" },
LAR: { actual: "LaborRefinishActualHours", flag: "LaborRefinishFlagHours", cost: "LaborRefinishCost" },
LAU: { actual: "LaborDetailActualHours", flag: "LaborDetailFlagHours", cost: "LaborDetailCost" },
LA1: { actual: "LaborOtherActualHours", flag: "LaborOtherFlagHours", cost: "LaborOtherCost" },
LA2: { actual: "LaborOtherActualHours", flag: "LaborOtherFlagHours", cost: "LaborOtherCost" },
LA3: { actual: "LaborOtherActualHours", flag: "LaborOtherFlagHours", cost: "LaborOtherCost" },
LA4: { actual: "LaborOtherActualHours", flag: "LaborOtherFlagHours", cost: "LaborOtherCost" }
};
return timetickets.map((ticket, idx) => {
const { ciecacode, employee, actualhrs = 0, productivehrs = 0, rate = 0 } = ticket;
const isFlatRate = employee?.flat_rate;
const hours = isFlatRate ? productivehrs : actualhrs;
const cost = rate * hours;
const laborDetail = {
LaborDetailLineNumber: idx + 1,
TechnicianNameFirst: employee?.first_name || "",
TechnicianNameLast: employee?.last_name || "",
LaborBodyActualHours: 0,
LaborMechanicalActualHours: 0,
LaborGlassActualHours: 0,
LaborStructuralActualHours: 0,
LaborElectricalActualHours: 0,
LaborAluminumActualHours: 0,
LaborRefinishActualHours: 0,
LaborDetailActualHours: 0,
LaborOtherActualHours: 0,
LaborBodyFlagHours: 0,
LaborMechanicalFlagHours: 0,
LaborGlassFlagHours: 0,
LaborStructuralFlagHours: 0,
LaborElectricalFlagHours: 0,
LaborAluminumFlagHours: 0,
LaborRefinishFlagHours: 0,
LaborDetailFlagHours: 0,
LaborOtherFlagHours: 0,
LaborBodyCost: 0,
LaborMechanicalCost: 0,
LaborGlassCost: 0,
LaborStructuralCost: 0,
LaborElectricalCost: 0,
LaborAluminumCost: 0,
LaborRefinishCost: 0,
LaborDetailCost: 0,
LaborOtherCost: 0
};
const effectiveCiecacode = ciecacode || "LA4";
if (codeToProps[effectiveCiecacode]) {
const { actual, flag, cost: costProp } = codeToProps[effectiveCiecacode];
laborDetail[actual] = actualhrs;
laborDetail[flag] = productivehrs;
laborDetail[costProp] = cost;
}
return laborDetail;
});
};
const StatusMapping = (status, md_ro_statuses) => {
//Possible return statuses EST, SCH, ARR, IPR, RDY, DEL, CLO, CAN, UNDEFINED.
const {

554
server/data/chatter-api.js Normal file
View File

@@ -0,0 +1,554 @@
/**
* Environment variables used by this file
* Chatter integration
* - CHATTER_API_CONCURRENCY
* - Maximum number of jobs/interactions posted concurrently *per shop* (within a single shop's batch).
* - Default: 5
* - Used by: createConcurrencyLimit(MAX_CONCURRENCY)
*
* - CHATTER_API_REQUESTS_PER_SECOND
* - Per-company outbound request rate (token bucket refill rate).
* - Default: 3
* - Must be a positive number; otherwise falls back to default.
* - Used by: createTokenBucketRateLimiter({ refillPerSecond })
*
* - CHATTER_API_BURST_CAPACITY
* - Per-company token bucket capacity (maximum burst size).
* - Default: equals CHATTER_API_REQUESTS_PER_SECOND (i.e., 3 unless overridden)
* - Must be a positive number; otherwise falls back to default.
* - Used by: createTokenBucketRateLimiter({ capacity })
*
* - CHATTER_API_MAX_RETRIES
* - Maximum number of attempts for posting an interaction before giving up.
* - Default: 6
* - Must be a positive integer; otherwise falls back to default.
* - Used by: postInteractionWithPolicy()
*
* - CHATTER_API_TOKEN
* - Optional override token for emergency/dev scenarios.
* - If set, bypasses Secrets Manager/Redis token retrieval and uses this value for all companies.
* - Used by: getChatterApiTokenCached()
*
* Notes
* - Per-company API tokens are otherwise fetched via getChatterApiToken(companyId) (Secrets Manager)
* and may be cached via `sessionUtils.getChatterToken/setChatterToken` (Redis-backed).
*/
const queries = require("../graphql-client/queries");
const moment = require("moment-timezone");
const logger = require("../utils/logger");
const { ChatterApiClient, getChatterApiToken, CHATTER_BASE_URL } = require("../chatter/chatter-client");
const client = require("../graphql-client/graphql-client").client;
const CHATTER_EVENT = process.env.NODE_ENV === "production" ? "delivery" : "TEST_INTEGRATION";
const MAX_CONCURRENCY = Number(process.env.CHATTER_API_CONCURRENCY || 5);
const CHATTER_REQUESTS_PER_SECOND = getPositiveNumber(process.env.CHATTER_API_REQUESTS_PER_SECOND, 3);
const CHATTER_BURST_CAPACITY = getPositiveNumber(process.env.CHATTER_API_BURST_CAPACITY, CHATTER_REQUESTS_PER_SECOND);
const CHATTER_MAX_RETRIES = getPositiveInteger(process.env.CHATTER_API_MAX_RETRIES, 6);
// Client caching (in-memory) - tokens are now cached in Redis
const clientCache = new Map(); // companyId -> ChatterApiClient
const tokenInFlight = new Map(); // companyId -> Promise<string> (for in-flight deduplication)
const companyRateLimiters = new Map(); // companyId -> rate limiter
/**
* Core processing function for Chatter API jobs.
* This can be called by the HTTP handler or the BullMQ worker.
*
* @param {Object} options - Processing options
* @param {string} options.start - Start date for the delivery window
* @param {string} options.end - End date for the delivery window
* @param {Array<string>} options.bodyshopIds - Optional specific shops to process
* @param {boolean} options.skipUpload - Dry-run flag
* @param {Object} options.sessionUtils - Optional session utils for token caching
* @returns {Promise<Object>} Result with totals, allShopSummaries, and allErrors
*/
async function processChatterApiJob({ start, end, bodyshopIds, skipUpload, sessionUtils }) {
logger.log("chatter-api-start", "DEBUG", "api", null, null);
const allErrors = [];
const allShopSummaries = [];
// Shops that DO have chatter_company_id
const { bodyshops } = await client.request(queries.GET_CHATTER_SHOPS_WITH_COMPANY);
const shopsToProcess =
bodyshopIds?.length > 0 ? bodyshops.filter((shop) => bodyshopIds.includes(shop.id)) : bodyshops;
logger.log("chatter-api-shopsToProcess-generated", "DEBUG", "api", null, { count: shopsToProcess.length });
if (shopsToProcess.length === 0) {
logger.log("chatter-api-shopsToProcess-empty", "DEBUG", "api", null, null);
return {
totals: { shops: 0, jobs: 0, sent: 0, duplicates: 0, failed: 0 },
allShopSummaries: [],
allErrors: []
};
}
await processBatchApi({
shopsToProcess,
start,
end,
skipUpload,
allShopSummaries,
allErrors,
sessionUtils
});
const totals = allShopSummaries.reduce(
(acc, s) => {
acc.shops += 1;
acc.jobs += s.jobs || 0;
acc.sent += s.sent || 0;
acc.duplicates += s.duplicates || 0;
acc.failed += s.failed || 0;
return acc;
},
{ shops: 0, jobs: 0, sent: 0, duplicates: 0, failed: 0 }
);
logger.log("chatter-api-end", "DEBUG", "api", null, totals);
return { totals, allShopSummaries, allErrors };
}
exports.default = async (req, res) => {
if (process.env.NODE_ENV !== "production") return res.sendStatus(403);
if (req.headers["x-imex-auth"] !== process.env.AUTOHOUSE_AUTH_TOKEN) return res.sendStatus(401);
res.status(202).json({
success: true,
message: "Chatter API job queued for processing",
timestamp: new Date().toISOString()
});
try {
const { dispatchChatterApiJob } = require("./queues/chatterApiQueue");
const { start, end, bodyshopIds, skipUpload } = req.body;
await dispatchChatterApiJob({
start,
end,
bodyshopIds,
skipUpload
});
} catch (error) {
logger.log("chatter-api-queue-dispatch-error", "ERROR", "api", null, {
error: error.message,
stack: error.stack
});
}
};
exports.processChatterApiJob = processChatterApiJob;
async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShopSummaries, allErrors, sessionUtils }) {
for (const bodyshop of shopsToProcess) {
const summary = {
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
shopname: bodyshop.shopname,
chatter_company_id: bodyshop.chatter_company_id,
chatterid: bodyshop.chatterid,
jobs: 0,
sent: 0,
duplicates: 0,
failed: 0,
ok: true
};
try {
logger.log("chatter-api-start-shop", "DEBUG", "api", bodyshop.id, { shopname: bodyshop.shopname });
const companyId = parseCompanyId(bodyshop.chatter_company_id);
if (!companyId) {
summary.ok = false;
summary.failed = 0;
allErrors.push({
...pickShop(bodyshop),
fatal: true,
errors: [`Invalid chatter_company_id: "${bodyshop.chatter_company_id}"`]
});
allShopSummaries.push(summary);
continue;
}
const chatterApi = await getChatterApiClient(companyId, sessionUtils);
const { jobs } = await client.request(queries.CHATTER_QUERY, {
bodyshopid: bodyshop.id,
start: start ? moment(start).startOf("day") : moment().subtract(1, "days").startOf("day"),
...(end && { end: moment(end).endOf("day") })
});
summary.jobs = jobs.length;
// concurrency-limited posting
const limit = createConcurrencyLimit(MAX_CONCURRENCY);
const results = await Promise.all(
jobs.map((j) =>
limit(async () => {
const payload = buildInteractionPayload(bodyshop, j);
// keep legacy flag name: skipUpload == dry-run
if (skipUpload) return { ok: true, dryRun: true };
const r = await postInteractionWithPolicy(chatterApi, companyId, payload);
return r;
})
)
);
for (const r of results) {
if (r?.dryRun) continue;
if (r?.ok && r?.duplicate) summary.duplicates += 1;
else if (r?.ok) summary.sent += 1;
else summary.failed += 1;
}
// record failures with some detail (cap to avoid huge emails)
const failures = results
.filter((r) => r && r.ok === false)
.slice(0, 25)
.map((r) => ({
status: r.status,
error: r.error,
context: r.context
}));
if (failures.length) {
summary.ok = false;
allErrors.push({
...pickShop(bodyshop),
fatal: false,
errors: failures
});
}
logger.log("chatter-api-end-shop", "DEBUG", "api", bodyshop.id, summary);
} catch (error) {
summary.ok = false;
logger.log("chatter-api-error-shop", "ERROR", "api", bodyshop.id, {
error: error.message,
stack: error.stack
});
allErrors.push({
...pickShop(bodyshop),
fatal: true,
errors: [error.toString()]
});
} finally {
allShopSummaries.push(summary);
}
}
}
function buildInteractionPayload(bodyshop, j) {
const isCompany = Boolean(j.ownr_co_nm);
const locationIdentifier = `${bodyshop.chatter_company_id}-${bodyshop.id}`;
const timestamp = formatChatterTimestamp(j.actual_delivery, bodyshop.timezone);
if (j.actual_delivery && !timestamp) {
logger.log("chatter-api-invalid-delivery-timestamp", "WARN", "api", bodyshop.id, {
bodyshopId: bodyshop.id,
jobId: j.id,
timezone: bodyshop.timezone,
actualDelivery: j.actual_delivery
});
}
return {
locationIdentifier: locationIdentifier,
event: CHATTER_EVENT,
consent: "true",
transactionId: j.ro_number != null ? String(j.ro_number) : undefined,
timestamp,
firstName: isCompany ? null : j.ownr_fn || null,
lastName: isCompany ? j.ownr_co_nm : j.ownr_ln || null,
emailAddress: j.ownr_ea || undefined,
phoneNumber: j.ownr_ph1 || undefined,
metadata: {
imexShopId: bodyshop.imexshopid,
bodyshopId: bodyshop.id,
jobId: j.id
}
};
}
async function postInteractionWithPolicy(chatterApi, companyId, payload) {
const limiter = getCompanyRateLimiter(companyId);
const requestContext = {
companyId,
locationIdentifier: payload?.locationIdentifier,
transactionId: payload?.transactionId,
timestamp: payload?.timestamp ?? null,
bodyshopId: payload?.metadata?.bodyshopId ?? null,
jobId: payload?.metadata?.jobId ?? null
};
for (let attempt = 0; attempt < CHATTER_MAX_RETRIES; attempt++) {
await limiter.acquire();
try {
await chatterApi.postInteraction(companyId, payload);
return { ok: true };
} catch (e) {
// duplicate -> treat as successful idempotency outcome
if (e.status === 409) return { ok: true, duplicate: true, error: e.data };
// rate limited -> backoff + retry
if (e.status === 429) {
const retryDelayMs = retryDelayMsForError(e, attempt);
limiter.pause(retryDelayMs);
logger.log("chatter-api-request-rate-limited", "WARN", "api", requestContext.bodyshopId, {
...requestContext,
attempt: attempt + 1,
maxAttempts: CHATTER_MAX_RETRIES,
status: e.status,
retryAfterMs: e.retryAfterMs,
retryDelayMs,
error: e.data ?? e.message
});
await sleep(retryDelayMs);
continue;
}
logger.log("chatter-api-request-failed", "ERROR", "api", requestContext.bodyshopId, {
...requestContext,
attempt: attempt + 1,
maxAttempts: CHATTER_MAX_RETRIES,
status: e.status,
error: e.data ?? e.message
});
return { ok: false, status: e.status, error: e.data ?? e.message, context: requestContext };
}
}
logger.log("chatter-api-request-failed", "ERROR", "api", requestContext.bodyshopId, {
...requestContext,
maxAttempts: CHATTER_MAX_RETRIES,
status: 429,
error: "rate limit retry exhausted"
});
return { ok: false, status: 429, error: "rate limit retry exhausted", context: requestContext };
}
function parseCompanyId(val) {
const s = String(val ?? "").trim();
if (!s) return null;
const n = Number(s);
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) return null;
return n;
}
function pickShop(bodyshop) {
return {
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
shopname: bodyshop.shopname,
chatter_company_id: bodyshop.chatter_company_id,
chatterid: bodyshop.chatterid
};
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function backoffMs(attempt) {
const base = Math.min(30_000, 500 * 2 ** attempt);
const jitter = Math.floor(Math.random() * 250);
return base + jitter;
}
function retryDelayMsForError(error, attempt) {
const retryAfterMs = Number(error?.retryAfterMs);
if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) {
const jitter = Math.floor(Math.random() * 250);
return Math.min(60_000, retryAfterMs + jitter);
}
return backoffMs(attempt);
}
function formatChatterTimestamp(value, timezone) {
if (!value) return undefined;
const hasValidTimezone = Boolean(timezone && moment.tz.zone(timezone));
const parsed = hasValidTimezone ? moment(value).tz(timezone) : moment(value);
if (!parsed.isValid()) return undefined;
// Keep a strict, Chatter-friendly timestamp without fractional seconds.
return parsed.utc().format("YYYY-MM-DD HH:mm:ss[Z]");
}
function createConcurrencyLimit(max) {
let active = 0;
const queue = [];
const next = () => {
if (active >= max) return;
const fn = queue.shift();
if (!fn) return;
active++;
fn()
.catch(() => {})
.finally(() => {
active--;
next();
});
};
return (fn) =>
new Promise((resolve, reject) => {
queue.push(async () => {
try {
resolve(await fn());
} catch (e) {
reject(e);
}
});
next();
});
}
function getCompanyRateLimiter(companyId) {
const key = String(companyId);
const existing = companyRateLimiters.get(key);
if (existing) return existing;
const limiter = createTokenBucketRateLimiter({
refillPerSecond: CHATTER_REQUESTS_PER_SECOND,
capacity: CHATTER_BURST_CAPACITY
});
companyRateLimiters.set(key, limiter);
return limiter;
}
function createTokenBucketRateLimiter({ refillPerSecond, capacity }) {
let tokens = capacity;
let lastRefillAt = Date.now();
let pauseUntil = 0;
let chain = Promise.resolve();
const refill = () => {
const now = Date.now();
const elapsedSec = (now - lastRefillAt) / 1000;
if (elapsedSec <= 0) return;
tokens = Math.min(capacity, tokens + elapsedSec * refillPerSecond);
lastRefillAt = now;
};
const waitForPermit = async () => {
for (;;) {
const now = Date.now();
if (pauseUntil > now) {
await sleep(pauseUntil - now);
continue;
}
refill();
if (tokens >= 1) {
tokens -= 1;
return;
}
const missing = 1 - tokens;
const waitMs = Math.max(25, Math.ceil((missing / refillPerSecond) * 1000));
await sleep(waitMs);
}
};
return {
acquire() {
chain = chain.then(waitForPermit, waitForPermit);
return chain;
},
pause(ms) {
const n = Number(ms);
if (!Number.isFinite(n) || n <= 0) return;
pauseUntil = Math.max(pauseUntil, Date.now() + n);
}
};
}
function getPositiveNumber(value, fallback) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
function getPositiveInteger(value, fallback) {
const n = Number(value);
return Number.isInteger(n) && n > 0 ? n : fallback;
}
/**
* Returns a per-company Chatter API client, caching both the token and the client.
*/
async function getChatterApiClient(companyId, sessionUtils) {
const key = String(companyId);
const existing = clientCache.get(key);
if (existing) return existing;
const apiToken = await getChatterApiTokenCached(companyId, sessionUtils);
const chatterApi = new ChatterApiClient({ baseUrl: CHATTER_BASE_URL, apiToken });
clientCache.set(key, chatterApi);
return chatterApi;
}
/**
* Fetches the per-company token from AWS Secrets Manager with Redis caching
* SecretId: CHATTER_COMPANY_KEY_<companyId>
*
* Uses Redis caching + in-flight dedupe to avoid hammering Secrets Manager.
*/
async function getChatterApiTokenCached(companyId, sessionUtils) {
const key = String(companyId ?? "").trim();
if (!key) throw new Error("getChatterApiToken: companyId is required");
// Optional override for emergency/dev
if (process.env.CHATTER_API_TOKEN) return process.env.CHATTER_API_TOKEN;
// Check Redis cache if sessionUtils is available
if (sessionUtils?.getChatterToken) {
const cachedToken = await sessionUtils.getChatterToken(key);
if (cachedToken) {
logger.log("chatter-api-get-token-cache-hit", "DEBUG", "api", null, { companyId: key });
return cachedToken;
}
}
// Check for in-flight requests
const inflight = tokenInFlight.get(key);
if (inflight) return inflight;
const p = (async () => {
logger.log("chatter-api-get-token-cache-miss", "DEBUG", "api", null, { companyId: key });
// Fetch token from Secrets Manager using shared function
const token = await getChatterApiToken(companyId);
// Store in Redis cache if sessionUtils is available
if (sessionUtils?.setChatterToken) {
await sessionUtils.setChatterToken(key, token);
}
return token;
})();
tokenInFlight.set(key, p);
try {
return await p;
} finally {
tokenInFlight.delete(key);
}
}

View File

@@ -4,6 +4,8 @@ const converter = require("json-2-csv");
const logger = require("../utils/logger");
const fs = require("fs");
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
const { defaultProvider } = require("@aws-sdk/credential-provider-node");
const { isString, isEmpty } = require("lodash");
let Client = require("ssh2-sftp-client");
const client = require("../graphql-client/graphql-client").client;
@@ -144,7 +146,18 @@ async function processBatch(shopsToProcess, start, end, allChatterObjects, allEr
async function getPrivateKey() {
// Connect to AWS Secrets Manager
const client = new SecretsManagerClient({ region: "ca-central-1" });
const secretsClientOptions = {
region: "ca-central-1",
credentials: defaultProvider()
};
const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME);
if (isLocal) {
secretsClientOptions.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`;
}
const client = new SecretsManagerClient(secretsClientOptions);
const command = new GetSecretValueCommand({ SecretId: "CHATTER_PRIVATE_KEY" });
logger.log("chatter-get-private-key", "DEBUG", "api", null, null);

View File

@@ -9,4 +9,5 @@ exports.emsUpload = require("./emsUpload").default;
exports.carfax = require("./carfax").default;
exports.carfaxRps = require("./carfax-rps").default;
exports.vehicletype = require("./vehicletype/vehicletype").default;
exports.documentAnalytics = require("./analytics/documents").default;
exports.documentAnalytics = require("./analytics/documents").default;
exports.chatterApi = require("./chatter-api").default;

View File

@@ -264,29 +264,30 @@ const CreateRepairOrderTag = (job, errorCallback) => {
}${job.est_ct_fn ? job.est_ct_fn : ""}`
},
Dates: {
DateEstimated: (job.date_estimated && moment(job.date_estimated).format(DateFormat)) || "",
DateOpened: (job.date_opened && moment(job.date_opened).format(DateFormat)) || "",
DateScheduled:
(job.scheduled_in && moment(job.scheduled_in).tz(job.bodyshop.timezone).format(DateFormat)) || "",
DateArrived: (job.actual_in && moment(job.actual_in).tz(job.bodyshop.timezone).format(DateFormat)) || "",
DateEstimated: job.date_estimated ? moment(job.date_estimated).format(DateFormat) : "",
DateOpened: job.date_open ? moment(job.date_open).tz(job.bodyshop.timezone).format(DateFormat) : "",
DateScheduled: job.scheduled_in ? moment(job.scheduled_in).tz(job.bodyshop.timezone).format(DateFormat) : "",
DateArrived: job.actual_in ? moment(job.actual_in).tz(job.bodyshop.timezone).format(DateFormat) : "",
DateStart: job.date_repairstarted
? (job.date_repairstarted && moment(job.date_repairstarted).tz(job.bodyshop.timezone).format(DateFormat)) ||
""
: (job.actual_in && moment(job.actual_in).tz(job.bodyshop.timezone).format(DateFormat)) || "",
DateScheduledCompletion:
(job.scheduled_completion && moment(job.scheduled_completion).tz(job.bodyshop.timezone).format(DateFormat)) ||
"",
DateCompleted:
(job.actual_completion && moment(job.actual_completion).tz(job.bodyshop.timezone).format(DateFormat)) || "",
DateScheduledDelivery:
(job.scheduled_delivery && moment(job.scheduled_delivery).tz(job.bodyshop.timezone).format(DateFormat)) || "",
DateDelivered:
(job.actual_delivery && moment(job.actual_delivery).tz(job.bodyshop.timezone).format(DateFormat)) || "",
DateInvoiced:
(job.date_invoiced && moment(job.date_invoiced).tz(job.bodyshop.timezone).format(DateFormat)) || "",
DateExported:
(job.date_exported && moment(job.date_exported).tz(job.bodyshop.timezone).format(DateFormat)) || "",
DateVoid: (job.date_void && moment(job.date_void).tz(job.bodyshop.timezone).format(DateFormat)) || ""
? moment(job.date_repairstarted).tz(job.bodyshop.timezone).format(DateFormat)
: job.actual_in
? moment(job.actual_in).tz(job.bodyshop.timezone).format(DateFormat)
: "",
DateScheduledCompletion: job.scheduled_completion
? moment(job.scheduled_completion).tz(job.bodyshop.timezone).format(DateFormat)
: "",
DateCompleted: job.actual_completion
? moment(job.actual_completion).tz(job.bodyshop.timezone).format(DateFormat)
: "",
DateScheduledDelivery: job.scheduled_delivery
? moment(job.scheduled_delivery).tz(job.bodyshop.timezone).format(DateFormat)
: "",
DateDelivered: job.actual_delivery
? moment(job.actual_delivery).tz(job.bodyshop.timezone).format(DateFormat)
: "",
DateInvoiced: job.date_invoiced ? moment(job.date_invoiced).tz(job.bodyshop.timezone).format(DateFormat) : "",
DateExported: job.date_exported ? moment(job.date_exported).tz(job.bodyshop.timezone).format(DateFormat) : "",
DateVoid: job.date_void ? moment(job.date_void).tz(job.bodyshop.timezone).format(DateFormat) : ""
},
JobLineDetails: (function () {
const joblineSource = Array.isArray(job.joblines) ? job.joblines : job.joblines ? [job.joblines] : [];

View File

@@ -0,0 +1,178 @@
const { Queue, Worker } = require("bullmq");
const { registerCleanupTask } = require("../../utils/cleanupManager");
const getBullMQPrefix = require("../../utils/getBullMQPrefix");
const devDebugLogger = require("../../utils/devDebugLogger");
const moment = require("moment-timezone");
const { sendServerEmail } = require("../../email/sendemail");
let chatterApiQueue;
let chatterApiWorker;
/**
* Initializes the Chatter API queue and worker.
*
* @param {Object} options - Configuration options for queue initialization.
* @param {Object} options.pubClient - Redis client instance for queue communication.
* @param {Object} options.logger - Logger instance for logging events and debugging.
* @param {Function} options.processJob - Function to process the Chatter API job.
* @param {Function} options.getChatterToken - Function to get Chatter token from Redis.
* @param {Function} options.setChatterToken - Function to set Chatter token in Redis.
* @returns {Queue} The initialized `chatterApiQueue` instance.
*/
const loadChatterApiQueue = async ({ pubClient, logger, processJob, getChatterToken, setChatterToken }) => {
if (!chatterApiQueue) {
const prefix = getBullMQPrefix();
devDebugLogger(`Initializing Chatter API Queue with prefix: ${prefix}`);
chatterApiQueue = new Queue("chatterApi", {
prefix,
connection: pubClient,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: false,
attempts: 3,
backoff: {
type: "exponential",
delay: 60000 // 1 minute base delay
}
}
});
chatterApiWorker = new Worker(
"chatterApi",
async (job) => {
const { start, end, bodyshopIds, skipUpload } = job.data;
logger.log("chatter-api-queue-job-start", "INFO", "api", null, {
jobId: job.id,
start,
end,
bodyshopIds,
skipUpload
});
try {
// Provide sessionUtils-like object with token caching functions
const sessionUtils = {
getChatterToken,
setChatterToken
};
const result = await processJob({
start,
end,
bodyshopIds,
skipUpload,
sessionUtils
});
logger.log("chatter-api-queue-job-complete", "INFO", "api", null, {
jobId: job.id,
totals: result.totals
});
// Send email summary
await sendServerEmail({
subject: `Chatter API Report ${moment().format("MM-DD-YY")}`,
text:
`Totals:\n${JSON.stringify(result.totals, null, 2)}\n\n` +
`Shop summaries:\n${JSON.stringify(result.allShopSummaries, null, 2)}\n\n` +
`Errors:\n${JSON.stringify(result.allErrors, null, 2)}\n`
});
return result;
} catch (error) {
logger.log("chatter-api-queue-job-error", "ERROR", "api", null, {
jobId: job.id,
error: error.message,
stack: error.stack
});
// Send error email
await sendServerEmail({
subject: `Chatter API Error ${moment().format("MM-DD-YY")}`,
text: `Job failed:\n${error.message}\n\n${error.stack}`
});
throw error;
}
},
{
prefix,
connection: pubClient,
concurrency: 1, // Process one job at a time
lockDuration: 14400000 // 4 hours - allow long-running jobs
}
);
// Event handlers
chatterApiWorker.on("completed", (job) => {
devDebugLogger(`Chatter API job ${job.id} completed`);
});
chatterApiWorker.on("failed", (job, err) => {
logger.log("chatter-api-queue-job-failed", "ERROR", "api", null, {
jobId: job?.id,
message: err?.message,
stack: err?.stack
});
});
chatterApiWorker.on("progress", (job, progress) => {
devDebugLogger(`Chatter API job ${job.id} progress: ${progress}%`);
});
// Register cleanup task
const shutdown = async () => {
devDebugLogger("Closing Chatter API queue worker...");
await chatterApiWorker.close();
devDebugLogger("Chatter API queue worker closed");
};
registerCleanupTask(shutdown);
}
return chatterApiQueue;
};
/**
* Retrieves the initialized `chatterApiQueue` instance.
*
* @returns {Queue} The `chatterApiQueue` instance.
* @throws {Error} If `chatterApiQueue` is not initialized.
*/
const getQueue = () => {
if (!chatterApiQueue) {
throw new Error("Chatter API queue not initialized. Ensure loadChatterApiQueue is called during bootstrap.");
}
return chatterApiQueue;
};
/**
* Dispatches a Chatter API job to the queue.
*
* @param {Object} options - Options for the job.
* @param {string} options.start - Start date for the delivery window.
* @param {string} options.end - End date for the delivery window.
* @param {Array<string>} options.bodyshopIds - Optional specific shops to process.
* @param {boolean} options.skipUpload - Dry-run flag.
* @returns {Promise<void>} Resolves when the job is added to the queue.
*/
const dispatchChatterApiJob = async ({ start, end, bodyshopIds, skipUpload }) => {
const queue = getQueue();
const jobData = {
start: start || moment().subtract(1, "days").startOf("day").toISOString(),
end: end || moment().endOf("day").toISOString(),
bodyshopIds: bodyshopIds || [],
skipUpload: skipUpload || false
};
await queue.add("process-chatter-api", jobData, {
jobId: `chatter-api-${moment().format("YYYY-MM-DD-HHmmss")}`
});
devDebugLogger(`Added Chatter API job to queue: ${JSON.stringify(jobData)}`);
};
module.exports = { loadChatterApiQueue, getQueue, dispatchChatterApiJob };

View File

@@ -235,18 +235,6 @@ async function MakeFortellisCall({
// jobid: socket?.recordid
// });
if (result.data.checkStatusAfterSeconds) {
return DelayedCallback({
delayMeta: result.data,
access_token,
SubscriptionID: SubscriptionMeta.subscriptionId,
ReqId,
departmentIds: DepartmentId
});
}
logger.log(
"fortellis-log-event-json",
"DEBUG",
@@ -261,6 +249,18 @@ async function MakeFortellisCall({
},
);
if (result.data.checkStatusAfterSeconds) {
return DelayedCallback({
delayMeta: result.data,
access_token,
SubscriptionID: SubscriptionMeta.subscriptionId,
ReqId,
departmentIds: DepartmentId,
jobid,
socket
});
}
return result.data;
} catch (error) {
const errorDetails = {
@@ -310,7 +310,7 @@ async function MakeFortellisCall({
//Some Fortellis calls return a batch result that isn't ready immediately.
//This function will check the status of the call and wait until it is ready.
//It will try 5 times before giving up.
async function DelayedCallback({ delayMeta, access_token, SubscriptionID, ReqId, departmentIds }) {
async function DelayedCallback({ delayMeta, access_token, SubscriptionID, ReqId, departmentIds, jobid, socket }) {
for (let index = 0; index < 5; index++) {
await sleep(delayMeta.checkStatusAfterSeconds * 1000);
//Check to see if the call is ready.
@@ -334,6 +334,19 @@ async function DelayedCallback({ delayMeta, access_token, SubscriptionID, ReqId,
//"Department-Id": departmentIds[0].id
}
});
logger.log(
"fortellis-log-event-json-DelayedCallback",
"DEBUG",
socket?.user?.email,
jobid,
{
requestcurl: batchResult.config.curlCommand,
reqid: batchResult.config.headers["Request-Id"] || null,
subscriptionId: batchResult.config.headers["Subscription-Id"] || null,
resultdata: batchResult.data,
resultStatus: batchResult.status
},
);
// await writeFortellisLogToFile({
// timestamp: new Date().toISOString(),
// reqId: ReqId,

View File

@@ -198,7 +198,7 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
);
const existingCustomerInDMSCustList = DMSCustList.find((c) => c.customerId === selectedCustomerId);
DMSCust = existingCustomerInDMSCustList || {
customerId: selectedCustomerId //This is the fall back in case it is the generic customer.
customerId: selectedCustomerId //This is the fall back in case it is the generic customer.
};
await setSessionTransactionData(
socket.id,
@@ -207,8 +207,6 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
DMSCust,
defaultFortellisTTL
);
} else {
CreateFortellisLogEvent(socket, "DEBUG", `{3.2} Creating new customer.`);
const DMSCustomerInsertResponse = await InsertDmsCustomer({ socket, redisHelpers, JobData });
@@ -227,14 +225,10 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
CreateFortellisLogEvent(socket, "DEBUG", `{4.1} Inserting new vehicle with ID: ID ${DMSVid.vehiclesVehId}`);
DMSVeh = await InsertDmsVehicle({ socket, redisHelpers, JobData, txEnvelope, DMSVid, DMSCust });
} else {
DMSVeh = await getSessionTransactionData(
socket.id,
getTransactionType(jobid),
FortellisCacheEnums.DMSVeh
)
DMSVeh = await getSessionTransactionData(socket.id, getTransactionType(jobid), FortellisCacheEnums.DMSVeh);
CreateFortellisLogEvent(socket, "DEBUG", `{4.3} Updating Existing Vehicle to associate to owner.`);
//Check to see if the vehicle needs to be updated - i.e. the owner is not the selected customer.
//Check to see if the vehicle needs to be updated - i.e. the owner is not the selected customer.
if (!DMSVeh?.owners.find((o) => o.id.value === DMSCust.customerId && o.id.assigningPartyId === "CURRENT")) {
DMSVeh = await UpdateDmsVehicle({
socket,
@@ -271,7 +265,11 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
if (DMSTransHeader.rtnCode === "0") {
try {
CreateFortellisLogEvent(socket, "DEBUG", `{6} Attempting to post Transaction with ID ${DMSTransHeader.transID}`);
CreateFortellisLogEvent(
socket,
"DEBUG",
`{6} Attempting to post Transaction with ID ${DMSTransHeader.transID}`
);
const DmsBatchTxnPost = await PostDmsBatchWip({ socket, redisHelpers, JobData }); // 2 in 1 call that includes a post and the transactions.
await setSessionTransactionData(
@@ -282,16 +280,14 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
defaultFortellisTTL
);
if (DmsBatchTxnPost.rtnCode === "0") {
//TODO: Validate this is a string and not #
//something
CreateFortellisLogEvent(socket, "DEBUG", `{6} Successfully posted transaction to DMS.`);
await MarkJobExported({ socket, jobid: JobData.id, JobData });
await MarkJobExported({ socket, jobid: JobData.id, JobData, redisHelpers });
try {
CreateFortellisLogEvent(socket, "DEBUG", `{7} Updating Service Vehicle History.`);
const DMSVehHistory = await InsertServiceVehicleHistory({ socket, redisHelpers, JobData });
await setSessionTransactionData(
@@ -302,24 +298,19 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
defaultFortellisTTL
);
} catch (error) {
CreateFortellisLogEvent(socket, "ERROR", `{7.1} Error posting vehicle service history. ${error.message}`);
}
socket.emit("export-success", JobData.id);
} else {
//There was something wrong. Throw an error to trigger clean up.
//There was something wrong. Throw an error to trigger clean up.
//throw new Error("Error posting DMS Batch Transaction");
}
} catch (error) {
//Clean up the transaction and insert a faild error code
// //Get the error code
CreateFortellisLogEvent(socket, "DEBUG", `{6.1} Getting errors for Transaction ID ${DMSTransHeader.transID}`);
const DmsError = await QueryDmsErrWip({ socket, redisHelpers, JobData });
// //Delete the transaction
CreateFortellisLogEvent(socket, "DEBUG", `{6.2} Deleting Transaction ID ${DMSTransHeader.transID}`);
@@ -345,7 +336,11 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
stack: error.stack,
data: error.errorData
});
await InsertFailedExportLog({ socket, JobData, error: error.errorData?.issues || [JSON.stringify(error.errorData)] });
await InsertFailedExportLog({
socket,
JobData,
error: error.errorData?.issues || [JSON.stringify(error.errorData)]
});
} finally {
//Ensure we always insert logEvents
//GQL to insert logevents.
@@ -374,7 +369,7 @@ async function CalculateDmsVid({ socket, JobData, redisHelpers }) {
jobid: JobData.id,
body: {}
});
return result;
return Array.isArray(result) ? result.filter((v) => v.vehiclesVehId !== null && v.vehiclesVehId !== "") : [];
} catch (error) {
handleFortellisApiError(socket, error, "CalculateDmsVid", {
vin: JobData.v_vin,
@@ -429,12 +424,12 @@ async function QueryDmsCustomerById({ socket, redisHelpers, JobData, CustomerId
async function QueryDmsCustomerByName({ socket, redisHelpers, JobData }) {
const ownerName =
JobData.ownr_co_nm && JobData.ownr_co_nm.trim() !== ""
//? [["firstName", JobData.ownr_co_nm.replace(replaceSpecialRegex, "").toUpperCase()]] // Commented out until we receive direction.
? [["phone", JobData.ownr_ph1?.replace(replaceSpecialRegex, "")]]
? //? [["firstName", JobData.ownr_co_nm.replace(replaceSpecialRegex, "").toUpperCase()]] // Commented out until we receive direction.
[["phone", JobData.ownr_ph1?.replace(/[^0-9]/g, "")]]
: [
["firstName", JobData.ownr_fn?.replace(replaceSpecialRegex, "").toUpperCase()],
["lastName", JobData.ownr_ln?.replace(replaceSpecialRegex, "").toUpperCase()]
];
["firstName", JobData.ownr_fn?.replace(/[^a-zA-Z0-9]/g, "").toUpperCase()],
["lastName", JobData.ownr_ln?.replace(/[^a-zA-Z0-9]/g, "").toUpperCase()]
];
try {
const result = await MakeFortellisCall({
...FortellisActions.QueryCustomerByName,
@@ -457,7 +452,7 @@ async function QueryDmsCustomerByName({ socket, redisHelpers, JobData }) {
async function InsertDmsCustomer({ socket, redisHelpers, JobData }) {
try {
const isBusiness = (JobData.ownr_co_nm && JobData.ownr_co_nm.replace(replaceSpecialRegex, "").trim() !== "")
const isBusiness = JobData.ownr_co_nm && JobData.ownr_co_nm.replace(replaceSpecialRegex, "").trim() !== "";
const result = await MakeFortellisCall({
...FortellisActions.CreateCustomer,
headers: {},
@@ -466,21 +461,23 @@ async function InsertDmsCustomer({ socket, redisHelpers, JobData }) {
jobid: JobData.id,
body: {
customerType: isBusiness ? "BUSINESS" : "INDIVIDUAL",
...isBusiness ? {
companyName: JobData.ownr_co_nm && JobData.ownr_co_nm.replace(replaceSpecialRegex, "").toUpperCase(),
secondaryCustomerName: {
//lastName: JobData.ownr_co_nm && JobData.ownr_co_nm.replace(replaceSpecialRegex, "").toUpperCase()
}
} : {
customerName: {
//"suffix": "Mr.",
firstName: JobData.ownr_fn && JobData.ownr_fn.replace(replaceSpecialRegex, "").toUpperCase(),
//"middleName": "",
lastName: JobData.ownr_ln && JobData.ownr_ln.replace(replaceSpecialRegex, "").toUpperCase()
//"title": "",
//"nickName": ""
}
},
...(isBusiness
? {
companyName: JobData.ownr_co_nm && JobData.ownr_co_nm.replace(replaceSpecialRegex, "").toUpperCase(),
secondaryCustomerName: {
//lastName: JobData.ownr_co_nm && JobData.ownr_co_nm.replace(replaceSpecialRegex, "").toUpperCase()
}
}
: {
customerName: {
//"suffix": "Mr.",
firstName: JobData.ownr_fn && JobData.ownr_fn.replace(/[^a-zA-Z0-9]/g, "").toUpperCase(),
//"middleName": "",
lastName: JobData.ownr_ln && JobData.ownr_ln.replace(/[^a-zA-Z0-9]/g, "").toUpperCase()
//"title": "",
//"nickName": ""
}
}),
postalAddress: {
addressLine1: JobData.ownr_addr1?.replace(replaceSpecialRegex, "").trim().toUpperCase(),
addressLine2: JobData.ownr_addr2?.replace(replaceSpecialRegex, "").trim().toUpperCase(),
@@ -490,7 +487,7 @@ async function InsertDmsCustomer({ socket, redisHelpers, JobData }) {
rome: JobData.ownr_zip
}),
state: JobData.ownr_st?.replace(replaceSpecialRegex, "").trim().toUpperCase(),
country: JobData.ownr_ctry?.replace(replaceSpecialRegex, "").trim().toUpperCase(),
country: JobData.ownr_ctry?.replace(replaceSpecialRegex, "").trim().toUpperCase()
//"territory": ""
},
// "birthDate": {
@@ -504,7 +501,7 @@ async function InsertDmsCustomer({ socket, redisHelpers, JobData }) {
phones: [
{
//"uuid": "",
number: JobData.ownr_ph1?.replace(replaceSpecialRegex, ""),
number: JobData.ownr_ph1?.replace(/[^0-9]/g, ""),
type: "HOME"
// "doNotCallIndicator": true,
// "doNotCallIndicatorDate": `null,
@@ -540,18 +537,18 @@ async function InsertDmsCustomer({ socket, redisHelpers, JobData }) {
emailAddresses: [
...(!_.isEmpty(JobData.ownr_ea)
? [
{
//"uuid": "",
address: JobData.ownr_ea.toUpperCase(),
type: "PERSONAL"
// "doNotEmailSource": "",
// "doNotEmail": false,
// "isPreferred": true,
// "transactionEmailNotificationOptIn": false,
// "optInRequestDate": null,
// "optInDate": null
}
]
{
//"uuid": "",
address: JobData.ownr_ea.toUpperCase(),
type: "PERSONAL"
// "doNotEmailSource": "",
// "doNotEmail": false,
// "isPreferred": true,
// "transactionEmailNotificationOptIn": false,
// "optInRequestDate": null,
// "optInDate": null
}
]
: [])
// {
// "uuid": "",
@@ -691,9 +688,9 @@ async function InsertDmsVehicle({ socket, redisHelpers, JobData, txEnvelope, DMS
txEnvelope.dms_unsold === true
? ""
: moment(txEnvelope.inservicedate)
//.tz(JobData.bodyshop.timezone)
.startOf("day")
.toISOString()
//.tz(JobData.bodyshop.timezone)
.startOf("day")
.toISOString()
}),
//"lastServiceDate": "2011-11-23",
vehicleId: DMSVid.vehiclesVehId
@@ -735,8 +732,8 @@ async function InsertDmsVehicle({ socket, redisHelpers, JobData, txEnvelope, DMS
txEnvelope.dms_unsold === true
? ""
: moment()
// .tz(JobData.bodyshop.timezone)
.format("YYYY-MM-DD"),
// .tz(JobData.bodyshop.timezone)
.format("YYYY-MM-DD"),
// "deliveryMileage": 4,
// "doorsQuantity": 4,
// "engineNumber": "",
@@ -753,8 +750,8 @@ async function InsertDmsVehicle({ socket, redisHelpers, JobData, txEnvelope, DMS
: String(JobData.plate_no).replace(/([^\w]|_)/g, "").length === 0
? null
: String(JobData.plate_no)
.replace(/([^\w]|_)/g, "")
.toUpperCase(),
.replace(/([^\w]|_)/g, "")
.toUpperCase(),
make: txEnvelope.dms_make,
// "model": "CC10753",
modelAbrev: txEnvelope.dms_model,
@@ -900,13 +897,13 @@ async function UpdateDmsVehicle({ socket, redisHelpers, JobData, DMSVeh, DMSCust
},
...(oldOwner
? [
{
id: {
assigningPartyId: "PREVIOUS",
value: oldOwner.id.value
{
id: {
assigningPartyId: "PREVIOUS",
value: oldOwner.id.value
}
}
}
]
]
: [])
];
}
@@ -936,24 +933,24 @@ async function UpdateDmsVehicle({ socket, redisHelpers, JobData, DMSVeh, DMSCust
txEnvelope.dms_unsold === true
? ""
: moment(DMSVehToSend.dealer.inServiceDate || txEnvelope.inservicedate)
// .tz(JobData.bodyshop.timezone)
.toISOString()
// .tz(JobData.bodyshop.timezone)
.toISOString()
})
},
vehicle: {
...DMSVehToSend.vehicle,
...(txEnvelope.dms_model_override
? {
make: txEnvelope.dms_make,
modelAbrev: txEnvelope.dms_model
}
make: txEnvelope.dms_make,
modelAbrev: txEnvelope.dms_model
}
: {}),
deliveryDate:
txEnvelope.dms_unsold === true
? ""
: moment(DMSVehToSend.vehicle.deliveryDate)
//.tz(JobData.bodyshop.timezone)
.toISOString()
//.tz(JobData.bodyshop.timezone)
.toISOString()
},
owners: ids
}
@@ -1061,7 +1058,7 @@ async function InsertDmsStartWip({ socket, redisHelpers, JobData }) {
// transID: "",
// userID: "partprgm",
// userName: "PROGRAM, PARTNER"
},
}
});
return result;
} catch (error) {
@@ -1091,9 +1088,9 @@ async function GenerateTransWips({ socket, redisHelpers, JobData }) {
acct: alloc.profitCenter.dms_acctnumber,
cntl:
alloc.profitCenter.dms_control_override &&
alloc.profitCenter.dms_control_override !== null &&
alloc.profitCenter.dms_control_override !== undefined &&
alloc.profitCenter.dms_control_override?.trim() !== ""
alloc.profitCenter.dms_control_override !== null &&
alloc.profitCenter.dms_control_override !== undefined &&
alloc.profitCenter.dms_control_override?.trim() !== ""
? alloc.profitCenter.dms_control_override
: JobData.ro_number,
cntl2: null,
@@ -1114,9 +1111,9 @@ async function GenerateTransWips({ socket, redisHelpers, JobData }) {
acct: alloc.costCenter.dms_acctnumber,
cntl:
alloc.costCenter.dms_control_override &&
alloc.costCenter.dms_control_override !== null &&
alloc.costCenter.dms_control_override !== undefined &&
alloc.costCenter.dms_control_override?.trim() !== ""
alloc.costCenter.dms_control_override !== null &&
alloc.costCenter.dms_control_override !== undefined &&
alloc.costCenter.dms_control_override?.trim() !== ""
? alloc.costCenter.dms_control_override
: JobData.ro_number,
cntl2: null,
@@ -1134,9 +1131,9 @@ async function GenerateTransWips({ socket, redisHelpers, JobData }) {
acct: alloc.costCenter.dms_wip_acctnumber,
cntl:
alloc.costCenter.dms_control_override &&
alloc.costCenter.dms_control_override !== null &&
alloc.costCenter.dms_control_override !== undefined &&
alloc.costCenter.dms_control_override?.trim() !== ""
alloc.costCenter.dms_control_override !== null &&
alloc.costCenter.dms_control_override !== undefined &&
alloc.costCenter.dms_control_override?.trim() !== ""
? alloc.costCenter.dms_control_override
: JobData.ro_number,
cntl2: null,
@@ -1158,9 +1155,9 @@ async function GenerateTransWips({ socket, redisHelpers, JobData }) {
acct: alloc.profitCenter.dms_acctnumber,
cntl:
alloc.profitCenter.dms_control_override &&
alloc.profitCenter.dms_control_override !== null &&
alloc.profitCenter.dms_control_override !== undefined &&
alloc.profitCenter.dms_control_override?.trim() !== ""
alloc.profitCenter.dms_control_override !== null &&
alloc.profitCenter.dms_control_override !== undefined &&
alloc.profitCenter.dms_control_override?.trim() !== ""
? alloc.profitCenter.dms_control_override
: JobData.ro_number,
cntl2: null,
@@ -1228,7 +1225,7 @@ async function PostDmsBatchWip({ socket, redisHelpers, JobData }) {
opCode: "P",
transID: DMSTransHeader.transID,
transWipReqList: await GenerateTransWips({ socket, redisHelpers, JobData })
},
}
});
return result;
} catch (error) {
@@ -1256,7 +1253,7 @@ async function QueryDmsErrWip({ socket, redisHelpers, JobData }) {
socket,
jobid: JobData.id,
requestPathParams: DMSTransHeader.transID,
body: {},
body: {}
});
return result;
} catch (error) {
@@ -1286,7 +1283,7 @@ async function DeleteDmsWip({ socket, redisHelpers, JobData }) {
body: {
opCode: "D",
transID: DMSTransHeader.transID
},
}
});
return result;
} catch (error) {
@@ -1298,9 +1295,15 @@ async function DeleteDmsWip({ socket, redisHelpers, JobData }) {
}
}
async function MarkJobExported({ socket, jobid, JobData }) {
async function MarkJobExported({ socket, jobid, JobData, redisHelpers }) {
CreateFortellisLogEvent(socket, "DEBUG", `Marking job as exported for id ${jobid}`);
const transwips = await redisHelpers.getSessionTransactionData(
socket.id,
getTransactionType(JobData.id),
FortellisCacheEnums.transWips
);
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {});
const currentToken =
(socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token);
@@ -1318,7 +1321,7 @@ async function MarkJobExported({ socket, jobid, JobData }) {
jobid: jobid,
successful: true,
useremail: socket.user.email,
metadata: socket.transWips
metadata: transwips
},
bill: {
exported: true,
@@ -1338,13 +1341,15 @@ async function InsertFailedExportLog({ socket, JobData, error }) {
const result = await client
.setHeaders({ Authorization: `Bearer ${currentToken}` })
.request(queries.INSERT_EXPORT_LOG, {
logs: [{
bodyshopid: JobData.bodyshop.id,
jobid: JobData.id,
successful: false,
message: JSON.stringify(error),
useremail: socket.user.email
}]
logs: [
{
bodyshopid: JobData.bodyshop.id,
jobid: JobData.id,
successful: false,
message: JSON.stringify(error),
useremail: socket.user.email
}
]
});
return result;

View File

@@ -827,13 +827,21 @@ exports.AUTOHOUSE_QUERY = `query AUTOHOUSE_EXPORT($start: timestamptz, $bodyshop
quantity
}
}
timetickets {
timetickets(where: {cost_center: {_neq: "timetickets.labels.shift"}}) {
id
rate
ciecacode
cost_center
actualhrs
productivehrs
flat_rate
employeeid
employee {
employee_number
flat_rate
first_name
last_name
}
}
area_of_damage
employee_prep_rel {
@@ -1612,6 +1620,9 @@ exports.GET_JOB_BY_PK = `query GET_JOB_BY_PK($id: uuid!) {
rate_ats
flat_rate_ats
rate_ats_flat
dms_id
dms_customer_id
dms_advisor_id
joblines(where: { removed: { _eq: false } }){
id
line_no
@@ -1911,10 +1922,35 @@ exports.GET_AUTOHOUSE_SHOPS = `query GET_AUTOHOUSE_SHOPS {
}`;
exports.GET_CHATTER_SHOPS = `query GET_CHATTER_SHOPS {
bodyshops(where: {chatterid: {_is_null: false}, _or: {chatterid: {_neq: ""}}}){
bodyshops(
where: {
chatterid: { _is_null: false, _neq: "" }
_or: [
{ chatter_company_id: { _is_null: true } }
{ chatter_company_id: { _eq: "" } }
]
}
) {
id
shopname
chatterid
chatter_company_id
imexshopid
timezone
}
}`;
exports.GET_CHATTER_SHOPS_WITH_COMPANY = `query GET_CHATTER_SHOPS_WITH_COMPANY {
bodyshops(
where: {
chatterid: { _is_null: false, _neq: "" }
chatter_company_id: { _is_null: false, _neq: "" }
}
) {
id
shopname
chatterid
chatter_company_id
imexshopid
timezone
}
@@ -3203,9 +3239,12 @@ exports.UPDATE_USER_FCM_TOKENS_BY_EMAIL = /* GraphQL */ `
}
`;
exports.SET_JOB_DMS_ID = `mutation SetJobDmsId($id: uuid!, $dms_id: String!) {
update_jobs_by_pk(pk_columns: { id: $id }, _set: { dms_id: $dms_id }) {
exports.SET_JOB_DMS_ID = `mutation SetJobDmsId($id: uuid!, $dms_id: String!, $dms_customer_id: String, $dms_advisor_id: String, $kmin: Int) {
update_jobs_by_pk(pk_columns: { id: $id }, _set: { dms_id: $dms_id, dms_customer_id: $dms_customer_id, dms_advisor_id: $dms_advisor_id, kmin: $kmin }) {
id
dms_id
dms_customer_id
dms_advisor_id
kmin
}
}`;

View File

@@ -13,6 +13,9 @@ const { DiscountNotAlreadyCounted } = InstanceManager({
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
const isImEX = InstanceManager({ imex: true, rome: false });
const isRome = InstanceManager({ imex: false, rome: true });
async function JobCosting(req, res) {
const { jobid } = req.body;
@@ -266,9 +269,7 @@ function GenerateCostingData(job) {
);
const materialsHours = { mapaHrs: 0, mashHrs: 0 };
let mashOpCodes = InstanceManager({
rome: ParseCalopCode(job.materials["MASH"]?.cal_opcode)
});
let mashOpCodes = isRome && ParseCalopCode(job.materials["MASH"]?.cal_opcode);
let hasMapaLine = false;
let hasMashLine = false;
@@ -355,7 +356,7 @@ function GenerateCostingData(job) {
if (val.mod_lbr_ty === "LAR") {
materialsHours.mapaHrs += val.mod_lb_hrs || 0;
}
if (InstanceManager({ imex: true, rome: false })) {
if (isImEX) {
if (val.mod_lbr_ty !== "LAR") {
materialsHours.mashHrs += val.mod_lb_hrs || 0;
}
@@ -363,7 +364,7 @@ function GenerateCostingData(job) {
if (val.mod_lbr_ty !== "LAR" && mashOpCodes.includes(val.lbr_op)) {
materialsHours.mashHrs += val.mod_lb_hrs || 0;
}
if (val.manual_line === true && !mashOpCodes.includes(val.lbr_op) && val.mod_lbr_ty !== "LAR" ) {
if (val.manual_line === true && !mashOpCodes.includes(val.lbr_op) && val.mod_lbr_ty !== "LAR") {
materialsHours.mashHrs += val.mod_lb_hrs || 0;
}
}
@@ -525,14 +526,15 @@ function GenerateCostingData(job) {
}
}
if (InstanceManager({ rome: true })) {
if (isRome) {
if (convertedKey) {
const correspondingCiecaStlTotalLine = job.cieca_stl?.data.find(
(c) => c.ttl_typecd === convertedKey.toUpperCase()
);
if (
correspondingCiecaStlTotalLine &&
Math.abs(jobLineTotalsByProfitCenter.parts[key].getAmount() - correspondingCiecaStlTotalLine.ttl_amt * 100) > 1
Math.abs(jobLineTotalsByProfitCenter.parts[key].getAmount() - correspondingCiecaStlTotalLine.ttl_amt * 100) >
1
) {
jobLineTotalsByProfitCenter.parts[key] = jobLineTotalsByProfitCenter.parts[key].add(disc).add(markup);
}
@@ -545,7 +547,7 @@ function GenerateCostingData(job) {
if (
job.materials["MAPA"] &&
job.materials["MAPA"].cal_maxdlr !== undefined &&
job.materials["MAPA"].cal_maxdlr >= 0
(isRome ? job.materials["MAPA"].cal_maxdlr >= 0 : job.materials["MAPA"].cal_maxdlr > 0)
) {
//It has an upper threshhold.
threshold = Dinero({
@@ -595,7 +597,7 @@ function GenerateCostingData(job) {
if (
job.materials["MASH"] &&
job.materials["MASH"].cal_maxdlr !== undefined &&
job.materials["MASH"].cal_maxdlr >= 0
(isRome ? job.materials["MASH"].cal_maxdlr >= 0 : job.materials["MASH"].cal_maxdlr > 0)
) {
//It has an upper threshhold.
threshold = Dinero({
@@ -641,7 +643,7 @@ function GenerateCostingData(job) {
}
}
if (InstanceManager({ imex: false, rome: true })) {
if (isRome) {
const stlTowing = job.cieca_stl?.data.find((c) => c.ttl_type === "OTTW");
const stlStorage = job.cieca_stl?.data.find((c) => c.ttl_type === "OTST");

View File

@@ -0,0 +1,12 @@
const express = require("express");
const createLocation = require("../chatter/createLocation");
const router = express.Router();
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
const validateAdminMiddleware = require("../middleware/validateAdminMiddleware");
router.use(validateFirebaseIdTokenMiddleware);
router.use(validateAdminMiddleware);
router.post("/create-location", createLocation);
module.exports = router;

View File

@@ -1,10 +1,21 @@
const express = require("express");
const router = express.Router();
const { autohouse, claimscorp, chatter, kaizen, usageReport, podium, carfax, carfaxRps } = require("../data/data");
const {
autohouse,
claimscorp,
chatter,
kaizen,
usageReport,
podium,
carfax,
carfaxRps,
chatterApi
} = require("../data/data");
router.post("/ah", autohouse);
router.post("/cc", claimscorp);
router.post("/chatter", chatter);
router.post("/chatter-api", chatterApi);
router.post("/kaizen", kaizen);
router.post("/usagereport", usageReport);
router.post("/podium", podium);

View File

@@ -86,8 +86,9 @@ const buildMessageJSONString = ({ error, classification, result, fallback }) =>
/**
* Success: mark job exported + (optionally) insert a success log.
* Uses queries.MARK_JOB_EXPORTED (same shape as Fortellis/PBS).
* @param {boolean} isEarlyRo - If true, only logs success but does NOT change job status (for early RO creation)
*/
const markRRExportSuccess = async ({ socket, jobId, job, bodyshop, result, metaExtra = {} }) => {
const markRRExportSuccess = async ({ socket, jobId, job, bodyshop, result, metaExtra = {}, isEarlyRo = false }) => {
const endpoint = process.env.GRAPHQL_ENDPOINT;
if (!endpoint) throw new Error("GRAPHQL_ENDPOINT not configured");
const token = getAuthToken(socket);
@@ -96,11 +97,40 @@ const markRRExportSuccess = async ({ socket, jobId, job, bodyshop, result, metaE
const client = new GraphQLClient(endpoint, {});
client.setHeaders({ Authorization: `Bearer ${token}` });
const meta = buildRRExportMeta({ result, extra: metaExtra });
// For early RO, we only insert a log but do NOT change job status or mark as exported
if (isEarlyRo) {
try {
await client.request(queries.INSERT_EXPORT_LOG, {
logs: [
{
bodyshopid: bodyshop?.id || job?.bodyshop?.id,
jobid: jobId,
successful: true,
useremail: socket?.user?.email || null,
metadata: meta,
message: buildMessageJSONString({ result, fallback: "RR early RO created" })
}
]
});
CreateRRLogEvent(socket, "INFO", "RR early RO: success log inserted (job status unchanged)", {
jobId
});
} catch (e) {
CreateRRLogEvent(socket, "ERROR", "RR early RO: failed to insert success log", {
jobId,
error: e?.message
});
}
return;
}
// Full export: mark job as exported and insert success log
const exportedStatus =
job?.bodyshop?.md_ro_statuses?.default_exported || bodyshop?.md_ro_statuses?.default_exported || "Exported*";
const meta = buildRRExportMeta({ result, extra: metaExtra });
try {
await client.request(queries.MARK_JOB_EXPORTED, {
jobId,

View File

@@ -56,7 +56,324 @@ const deriveRRStatus = (rrRes = {}) => {
};
/**
* Step 1: Export a job to RR as a new Repair Order.
* Early RO Creation: Create a minimal RR Repair Order with basic info (customer, advisor, mileage, story).
* Used when creating RO from convert button or admin page before full job export.
* @param args
* @returns {Promise<{success: boolean, data: *, roStatus: {status: *, statusCode: *|undefined, message}, statusBlocks: *|{}, customerNo: string, svId: *, roNo: *, xml: *}>}
*/
const createMinimalRRRepairOrder = async (args) => {
const { bodyshop, job, advisorNo, selectedCustomer, txEnvelope, socket, svId } = args || {};
if (!bodyshop) throw new Error("createMinimalRRRepairOrder: bodyshop is required");
if (!job) throw new Error("createMinimalRRRepairOrder: job is required");
if (advisorNo == null || String(advisorNo).trim() === "") {
throw new Error("createMinimalRRRepairOrder: advisorNo is required for RR");
}
// Resolve customer number (accept multiple shapes)
const selected = selectedCustomer?.customerNo || selectedCustomer?.custNo;
if (!selected) throw new Error("createMinimalRRRepairOrder: selectedCustomer.custNo/customerNo is required");
const { client, opts } = buildClientAndOpts(bodyshop);
// For early RO creation we always "Insert" (create minimal RO)
const finalOpts = {
...opts,
envelope: {
...(opts?.envelope || {}),
sender: {
...(opts?.envelope?.sender || {}),
task: "BSMRO",
referenceId: "Insert"
}
}
};
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null;
// Build minimal RO payload - just header, no allocations/parts/labor
const cleanVin =
(job?.v_vin || "")
.toString()
.replace(/[^A-Za-z0-9]/g, "")
.toUpperCase()
.slice(0, 17) || undefined;
// Resolve mileage - must be a positive number
let mileageIn = txEnvelope?.kmin ?? job?.kmin ?? null;
if (mileageIn != null) {
mileageIn = parseInt(mileageIn, 10);
if (isNaN(mileageIn) || mileageIn < 0) {
mileageIn = null;
}
}
CreateRRLogEvent(socket, "DEBUG", "Resolved mileage for early RO", {
txEnvelopeKmin: txEnvelope?.kmin,
jobKmin: job?.kmin,
resolvedMileageIn: mileageIn
});
const payload = {
customerNo: String(selected),
advisorNo: String(advisorNo),
vin: cleanVin,
departmentType: "B",
outsdRoNo: job?.ro_number || job?.id || undefined,
estimate: {
parts: "0",
labor: "0",
total: "0.00"
}
};
// Only add mileageIn if we have a valid value
if (mileageIn != null && mileageIn >= 0) {
payload.mileageIn = mileageIn;
}
// Add optional fields if present
if (story) {
payload.roComment = story;
}
if (makeOverride) {
payload.makeOverride = makeOverride;
}
CreateRRLogEvent(socket, "INFO", "Creating minimal RR Repair Order (early creation)", {
payload
});
const response = await client.createRepairOrder(payload, finalOpts);
CreateRRLogEvent(socket, "INFO", "RR minimal Repair Order created", {
payload,
response
});
const data = response?.data || null;
const statusBlocks = response?.statusBlocks || {};
const roStatus = deriveRRStatus(response);
const statusUpper = roStatus?.status ? String(roStatus.status).toUpperCase() : null;
let success = false;
if (statusUpper) {
// Treat explicit FAILURE / ERROR as hard failures
success = !["FAILURE", "ERROR"].includes(statusUpper);
} else if (typeof response?.success === "boolean") {
// Fallback to library boolean if no explicit status
success = response.success;
} else if (roStatus?.status) {
success = String(roStatus.status).toUpperCase() === "SUCCESS";
}
// Extract canonical roNo for later updates
const roNo = data?.dmsRoNo ?? data?.outsdRoNo ?? roStatus?.dmsRoNo ?? null;
return {
success,
data,
roStatus,
statusBlocks,
customerNo: String(selected),
svId,
roNo,
xml: response?.xml // expose XML for logging/diagnostics
};
};
/**
* Full Data Update: Update an existing RR Repair Order with complete job data (allocations, parts, labor).
* Used during DMS post form when an early RO was already created.
* @param args
* @returns {Promise<{success: boolean, data: *, roStatus: {status: *, statusCode: *|undefined, message}, statusBlocks: *|{}, customerNo: string, svId: *, roNo: *, xml: *}>}
*/
const updateRRRepairOrderWithFullData = async (args) => {
const { bodyshop, job, advisorNo, selectedCustomer, txEnvelope, socket, svId, roNo } = args || {};
if (!bodyshop) throw new Error("updateRRRepairOrderWithFullData: bodyshop is required");
if (!job) throw new Error("updateRRRepairOrderWithFullData: job is required");
if (advisorNo == null || String(advisorNo).trim() === "") {
throw new Error("updateRRRepairOrderWithFullData: advisorNo is required for RR");
}
if (!roNo) throw new Error("updateRRRepairOrderWithFullData: roNo is required for update");
// Resolve customer number (accept multiple shapes)
const selected = selectedCustomer?.customerNo || selectedCustomer?.custNo;
if (!selected) throw new Error("updateRRRepairOrderWithFullData: selectedCustomer.custNo/customerNo is required");
const { client, opts } = buildClientAndOpts(bodyshop);
// For full data update after early RO, we still use "Insert" referenceId
// because we're inserting the job operations for the first time
const finalOpts = {
...opts,
envelope: {
...(opts?.envelope || {}),
sender: {
...(opts?.envelope?.sender || {}),
task: "BSMRO",
referenceId: "Insert"
}
}
};
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null;
// Optional RR OpCode segments coming from the FE (RRPostForm)
const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null;
const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null;
const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null;
// RR-only extras
let rrCentersConfig = null;
let allocations = null;
let opCode = null;
// 1) Responsibility center config (for visibility / debugging)
try {
rrCentersConfig = extractRrResponsibilityCenters(bodyshop);
CreateRRLogEvent(socket, "SILLY", "RR responsibility centers resolved", {
hasCenters: !!bodyshop.md_responsibility_centers,
profitCenters: Object.keys(rrCentersConfig?.profitsByName || {}),
costCenters: Object.keys(rrCentersConfig?.costsByName || {}),
dmsCostDefaults: rrCentersConfig?.dmsCostDefaults || {},
dmsProfitDefaults: rrCentersConfig?.dmsProfitDefaults || {}
});
} catch (e) {
CreateRRLogEvent(socket, "ERROR", "Failed to resolve RR responsibility centers", {
message: e?.message,
stack: e?.stack
});
}
// 2) Allocations (sales + cost by center, with rr_* metadata already attached)
try {
const allocResult = await CdkCalculateAllocations(socket, job.id);
// We only need the per-center job allocations for RO.GOG / ROLABOR.
allocations = Array.isArray(allocResult?.jobAllocations) ? allocResult.jobAllocations : [];
CreateRRLogEvent(socket, "INFO", "RR allocations resolved for update", {
hasAllocations: allocations.length > 0,
count: allocations.length,
allocationsPreview: allocations.slice(0, 2).map(a => ({
type: a?.type,
code: a?.code,
laborSale: a?.laborSale,
laborCost: a?.laborCost,
partsSale: a?.partsSale,
partsCost: a?.partsCost
})),
taxAllocCount: Array.isArray(allocResult?.taxAllocArray) ? allocResult.taxAllocArray.length : 0,
ttlAdjCount: Array.isArray(allocResult?.ttlAdjArray) ? allocResult.ttlAdjArray.length : 0,
ttlTaxAdjCount: Array.isArray(allocResult?.ttlTaxAdjArray) ? allocResult.ttlTaxAdjArray.length : 0
});
} catch (e) {
CreateRRLogEvent(socket, "ERROR", "Failed to calculate RR allocations", {
message: e?.message,
stack: e?.stack
});
// Proceed with a header-only update if allocations fail.
allocations = [];
}
const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop);
let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null;
// If the FE only sends segments, combine them here.
if (!opCodeOverride && (opPrefix || opBase || opSuffix)) {
const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim();
if (combined) {
opCodeOverride = combined;
}
}
if (opCodeOverride || resolvedBaseOpCode) {
opCode = String(opCodeOverride || resolvedBaseOpCode).trim() || null;
}
CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", {
opCode,
baseFromConfig: resolvedBaseOpCode,
opPrefix,
opBase,
opSuffix
});
// Build full RO payload for update with allocations
const payload = buildRRRepairOrderPayload({
bodyshop,
job,
selectedCustomer: { customerNo: String(selected), custNo: String(selected) },
advisorNo: String(advisorNo),
story,
makeOverride,
allocations,
opCode
});
// Add roNo for linking to existing RO
payload.roNo = String(roNo);
payload.outsdRoNo = job?.ro_number || job?.id || undefined;
// Keep rolabor - it's needed to register the job/OpCode accounts in Reynolds
// Without this, Reynolds won't recognize the OpCode when we send rogg operations
// The rolabor section tells Reynolds "these jobs exist" even with minimal data
CreateRRLogEvent(socket, "INFO", "Sending full data for early RO (using create with roNo)", {
roNo: String(roNo),
hasRolabor: !!payload.rolabor,
hasRogg: !!payload.rogg,
payload
});
// Use createRepairOrder (not update) with the roNo to link to the existing early RO
// Reynolds will merge this with the existing RO header
const response = await client.createRepairOrder(payload, finalOpts);
CreateRRLogEvent(socket, "INFO", "RR Repair Order full data sent", {
payload,
response
});
const data = response?.data || null;
const statusBlocks = response?.statusBlocks || {};
const roStatus = deriveRRStatus(response);
const statusUpper = roStatus?.status ? String(roStatus.status).toUpperCase() : null;
let success = false;
if (statusUpper) {
success = !["FAILURE", "ERROR"].includes(statusUpper);
} else if (typeof response?.success === "boolean") {
success = response.success;
} else if (roStatus?.status) {
success = String(roStatus.status).toUpperCase() === "SUCCESS";
}
return {
success,
data,
roStatus,
statusBlocks,
customerNo: String(selected),
svId,
roNo: String(roNo),
xml: response?.xml
};
};
/**
* LEGACY: Step 1: Export a job to RR as a new Repair Order with full data.
* This is the original function - kept for backward compatibility if shops don't use early RO creation.
* @param args
* @returns {Promise<{success: boolean, data: *, roStatus: {status: *, statusCode: *|undefined, message}, statusBlocks: *|{}, customerNo: string, svId: *, roNo: *, xml: *}>}
*/
@@ -315,4 +632,10 @@ const finalizeRRRepairOrder = async (args) => {
};
};
module.exports = { exportJobToRR, finalizeRRRepairOrder, deriveRRStatus };
module.exports = {
exportJobToRR,
createMinimalRRRepairOrder,
updateRRRepairOrderWithFullData,
finalizeRRRepairOrder,
deriveRRStatus
};

View File

@@ -1,7 +1,12 @@
const CreateRRLogEvent = require("./rr-logger-event");
const { rrCombinedSearch, rrGetAdvisors, buildClientAndOpts } = require("./rr-lookup");
const { QueryJobData, buildRogogFromAllocations, buildRolaborFromRogog } = require("./rr-job-helpers");
const { exportJobToRR, finalizeRRRepairOrder } = require("./rr-job-export");
const {
exportJobToRR,
createMinimalRRRepairOrder,
updateRRRepairOrderWithFullData,
finalizeRRRepairOrder
} = require("./rr-job-export");
const RRCalculateAllocations = require("./rr-calculate-allocations").default;
const { createRRCustomer } = require("./rr-customers");
const { ensureRRServiceVehicle } = require("./rr-service-vehicles");
@@ -124,13 +129,15 @@ const getBodyshopForSocket = async ({ bodyshopId, socket }) => {
};
/**
* GraphQL mutation to set job.dms_id
* GraphQL mutation to set job.dms_id, dms_customer_id, and dms_advisor_id
* @param socket
* @param jobId
* @param dmsId
* @param dmsCustomerId
* @param dmsAdvisorId
* @returns {Promise<void>}
*/
const setJobDmsIdForSocket = async ({ socket, jobId, dmsId }) => {
const setJobDmsIdForSocket = async ({ socket, jobId, dmsId, dmsCustomerId, dmsAdvisorId, mileageIn }) => {
if (!jobId || !dmsId) {
CreateRRLogEvent(socket, "WARN", "setJobDmsIdForSocket called without jobId or dmsId", {
jobId,
@@ -149,16 +156,28 @@ const setJobDmsIdForSocket = async ({ socket, jobId, dmsId }) => {
const client = new GraphQLClient(endpoint, {});
await client
.setHeaders({ Authorization: `Bearer ${token}` })
.request(queries.SET_JOB_DMS_ID, { id: jobId, dms_id: String(dmsId) });
.request(queries.SET_JOB_DMS_ID, {
id: jobId,
dms_id: String(dmsId),
dms_customer_id: dmsCustomerId ? String(dmsCustomerId) : null,
dms_advisor_id: dmsAdvisorId ? String(dmsAdvisorId) : null,
kmin: mileageIn != null && mileageIn > 0 ? parseInt(mileageIn, 10) : null
});
CreateRRLogEvent(socket, "INFO", "Linked job.dms_id to RR RO", {
jobId,
dmsId: String(dmsId)
dmsId: String(dmsId),
dmsCustomerId,
dmsAdvisorId,
mileageIn
});
} catch (err) {
CreateRRLogEvent(socket, "ERROR", "Failed to set job.dms_id after RR create/update", {
jobId,
dmsId,
dmsCustomerId,
dmsAdvisorId,
mileageIn,
message: err?.message || String(err),
stack: err?.stack
});
@@ -373,7 +392,504 @@ const registerRREvents = ({ socket, redisHelpers }) => {
}
});
socket.on("rr-export-job", async ({ jobid, jobId, txEnvelope } = {}) => {
/**
* NEW: Early RO Creation Event
* Creates a minimal RO from convert button or admin page with customer selection,
* advisor, mileage, and optional story/overrides.
*/
socket.on("rr-create-early-ro", async ({ jobid, jobId, txEnvelope } = {}) => {
const rid = resolveJobId(jobid || jobId, { jobId, jobid }, null);
try {
if (!rid) throw new Error("RR early create: jobid required");
CreateRRLogEvent(socket, "DEBUG", `{EARLY-1} Received RR early RO creation request`, { jobid: rid });
// Cache txEnvelope (contains advisor, mileage, story, overrides)
await redisHelpers.setSessionTransactionData(
socket.id,
getTransactionType(rid),
RRCacheEnums.txEnvelope,
txEnvelope || {},
defaultRRTTL
);
CreateRRLogEvent(socket, "DEBUG", `{EARLY-1.1} Cached txEnvelope`, { hasTxEnvelope: !!txEnvelope });
const job = await QueryJobData({ redisHelpers }, rid);
await redisHelpers.setSessionTransactionData(
socket.id,
getTransactionType(rid),
RRCacheEnums.JobData,
job,
defaultRRTTL
);
CreateRRLogEvent(socket, "DEBUG", `{EARLY-1.2} Cached JobData`, { vin: job?.v_vin, ro: job?.ro_number });
const adv = readAdvisorNo(
{ txEnvelope },
await redisHelpers.getSessionTransactionData(socket.id, getTransactionType(rid), RRCacheEnums.AdvisorNo)
);
if (adv) {
await redisHelpers.setSessionTransactionData(
socket.id,
getTransactionType(rid),
RRCacheEnums.AdvisorNo,
String(adv),
defaultRRTTL
);
CreateRRLogEvent(socket, "DEBUG", `{EARLY-1.3} Cached advisorNo`, { advisorNo: String(adv) });
}
const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
const bodyshop = await getBodyshopForSocket({ bodyshopId, socket });
CreateRRLogEvent(socket, "DEBUG", `{EARLY-2} Running multi-search (Full Name + VIN)`);
const candidates = await rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers });
const decorated = candidates.map((c) => (c.vinOwner != null ? c : { ...c, vinOwner: !!c.isVehicleOwner }));
socket.emit("rr-select-customer", decorated);
CreateRRLogEvent(socket, "DEBUG", `{EARLY-2.1} Emitted rr-select-customer for early RO`, {
count: decorated.length,
anyOwner: decorated.some((c) => c.vinOwner || c.isVehicleOwner)
});
} catch (error) {
CreateRRLogEvent(socket, "ERROR", `Error during RR early RO creation (prepare)`, {
error: error.message,
stack: error.stack,
jobid: rid
});
try {
socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message });
} catch {
//
}
}
});
/**
* NEW: Early RO Customer Selected Event
* Handles customer selection for early RO creation and creates minimal RO.
*/
socket.on("rr-early-customer-selected", async ({ jobid, jobId, selectedCustomerId, custNo, create } = {}, ack) => {
const rid = resolveJobId(jobid || jobId, { jobid, jobId }, null);
let bodyshop = null;
let job = null;
let createdCustomer = false;
try {
if (!rid) throw new Error("jobid required");
CreateRRLogEvent(socket, "DEBUG", `{EARLY-3} rr-early-customer-selected`, {
jobid: rid,
custNo,
selectedCustomerId,
create: !!create
});
const ns = getTransactionType(rid);
CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.0a} Raw parameters received`, {
custNo: custNo,
custNoType: typeof custNo,
selectedCustomerId: selectedCustomerId,
create: create
});
let selectedCustNo =
(custNo && String(custNo)) ||
(selectedCustomerId && String(selectedCustomerId)) ||
(await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.SelectedCustomer));
CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.0b} After initial resolution`, {
selectedCustNo,
selectedCustNoType: typeof selectedCustNo
});
// Filter out invalid values
if (selectedCustNo === "undefined" || selectedCustNo === "null" || (selectedCustNo && selectedCustNo.trim() === "")) {
selectedCustNo = null;
}
CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.0} Resolved customer selection`, {
selectedCustNo,
willCreateNew: create === true || !selectedCustNo
});
job = await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.JobData);
const txEnvelope = (await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.txEnvelope)) || {};
if (!job) throw new Error("Staged JobData not found (run rr-create-early-ro first).");
const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
bodyshop = await getBodyshopForSocket({ bodyshopId, socket });
// Create customer (if requested or none chosen)
if (create === true || !selectedCustNo) {
CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.1} Creating RR customer`);
const created = await createRRCustomer({ bodyshop, job, socket });
selectedCustNo = String(created?.customerNo || "");
CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.2} Created customer`, {
custNo: selectedCustNo,
createdCustomerNo: created?.customerNo
});
if (!selectedCustNo || selectedCustNo === "undefined" || selectedCustNo.trim() === "") {
throw new Error("RR create customer returned no valid custNo");
}
createdCustomer = true;
}
// VIN owner pre-check
try {
const vehQ = makeVehicleSearchPayloadFromJob(job);
if (vehQ && vehQ.kind === "vin" && job?.v_vin) {
const vinResponse = await rrCombinedSearch(bodyshop, vehQ);
CreateRRLogEvent(socket, "SILLY", `VIN owner pre-check response (early RO)`, { response: vinResponse });
const vinBlocks = Array.isArray(vinResponse?.data) ? vinResponse.data : [];
try {
await redisHelpers.setSessionTransactionData(
socket.id,
ns,
RRCacheEnums.VINCandidates,
vinBlocks,
defaultRRTTL
);
} catch {
//
}
const ownersSet = ownersFromVinBlocks(vinBlocks, job.v_vin);
if (ownersSet?.size) {
const sel = String(selectedCustNo);
if (!ownersSet.has(sel)) {
const [existingOwner] = Array.from(ownersSet).map(String);
CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.2a} VIN exists; switching to VIN owner`, {
vin: job.v_vin,
selected: sel,
existingOwner
});
selectedCustNo = existingOwner;
}
}
}
} catch (e) {
CreateRRLogEvent(socket, "WARN", `VIN owner pre-check failed; continuing with selected customer (early RO)`, {
error: e?.message
});
}
// Cache final/effective customer selection
const effectiveCustNo = String(selectedCustNo);
await redisHelpers.setSessionTransactionData(
socket.id,
ns,
RRCacheEnums.SelectedCustomer,
effectiveCustNo,
defaultRRTTL
);
CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.3} Cached selected customer`, { custNo: effectiveCustNo });
// Build client & routing
const { client, opts } = await buildClientAndOpts(bodyshop);
const routing = opts?.routing || client?.opts?.routing || null;
if (!routing?.dealerNumber) throw new Error("ensureRRServiceVehicle: routing.dealerNumber required");
// Reconstruct a lightweight tx object
const tx = {
jobData: {
...job,
vin: job?.v_vin
},
txEnvelope
};
const vin = resolveVin({ tx, job });
if (!vin) {
CreateRRLogEvent(socket, "ERROR", "{EARLY-3.x} No VIN found for ensureRRServiceVehicle", { jobid: rid });
throw new Error("ensureRRServiceVehicle: vin required");
}
CreateRRLogEvent(socket, "DEBUG", "{EARLY-3.4} ensureRRServiceVehicle: starting", {
jobid: rid,
selectedCustomerNo: effectiveCustNo,
vin,
dealerNumber: routing.dealerNumber,
storeNumber: routing.storeNumber,
areaNumber: routing.areaNumber
});
const ensured = await ensureRRServiceVehicle({
client,
routing,
bodyshop,
selectedCustomerNo: effectiveCustNo,
custNo: effectiveCustNo,
customerNo: effectiveCustNo,
vin,
job,
socket,
redisHelpers
});
CreateRRLogEvent(socket, "DEBUG", "{EARLY-3.5} ensureRRServiceVehicle: done", ensured);
const cachedAdvisor = await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.AdvisorNo);
const advisorNo = readAdvisorNo({ txEnvelope }, cachedAdvisor);
if (!advisorNo) {
CreateRRLogEvent(socket, "ERROR", `Advisor is required (advisorNo) for early RO`);
await insertRRFailedExportLog({
socket,
jobId: rid,
job,
bodyshop,
error: new Error("Advisor is required (advisorNo)."),
classification: { errorCode: "RR_MISSING_ADVISOR", friendlyMessage: "Advisor is required." }
});
socket.emit("export-failed", { vendor: "rr", jobId: rid, error: "Advisor is required (advisorNo)." });
return ack?.({ ok: false, error: "Advisor is required (advisorNo)." });
}
await redisHelpers.setSessionTransactionData(
socket.id,
ns,
RRCacheEnums.AdvisorNo,
String(advisorNo),
defaultRRTTL
);
// CREATE MINIMAL RO (early creation)
CreateRRLogEvent(socket, "DEBUG", `{EARLY-4} Creating minimal RR RO`);
const result = await createMinimalRRRepairOrder({
bodyshop,
job,
selectedCustomer: { customerNo: effectiveCustNo, custNo: effectiveCustNo },
advisorNo: String(advisorNo),
txEnvelope,
socket,
svId: ensured?.svId || null
});
// Cache raw export result + pending RO number
await redisHelpers.setSessionTransactionData(
socket.id,
ns,
RRCacheEnums.ExportResult,
result || {},
defaultRRTTL
);
if (result?.success) {
const data = result?.data || {};
// Prefer explicit return from export function; then fall back to fields
const dmsRoNo = result?.roNo ?? data?.dmsRoNo ?? null;
const outsdRoNo = data?.outsdRoNo ?? job?.ro_number ?? job?.id ?? null;
CreateRRLogEvent(socket, "DEBUG", "Early RO created - checking dmsRoNo", {
dmsRoNo,
resultRoNo: result?.roNo,
dataRoNo: data?.dmsRoNo,
jobId: rid
});
// ✅ Persist DMS RO number, customer ID, advisor ID, and mileage on the job
if (dmsRoNo) {
const mileageIn = txEnvelope?.kmin ?? null;
CreateRRLogEvent(socket, "DEBUG", "Calling setJobDmsIdForSocket", {
jobId: rid,
dmsId: dmsRoNo,
customerId: effectiveCustNo,
advisorId: String(advisorNo),
mileageIn
});
await setJobDmsIdForSocket({
socket,
jobId: rid,
dmsId: dmsRoNo,
dmsCustomerId: effectiveCustNo,
dmsAdvisorId: String(advisorNo),
mileageIn
});
} else {
CreateRRLogEvent(socket, "WARN", "RR early RO creation succeeded but no DMS RO number was returned", {
jobId: rid,
resultPreview: {
roNo: result?.roNo,
data: {
dmsRoNo: data?.dmsRoNo,
outsdRoNo: data?.outsdRoNo
}
}
});
}
await redisHelpers.setSessionTransactionData(
socket.id,
ns,
RRCacheEnums.PendingRO,
{
outsdRoNo,
dmsRoNo,
customerNo: String(effectiveCustNo),
advisorNo: String(advisorNo),
vin: job?.v_vin || null,
earlyRoCreated: true // Flag to indicate this was an early RO
},
defaultRRTTL
);
CreateRRLogEvent(socket, "INFO", `{EARLY-5} Minimal RO created successfully`, {
dmsRoNo: dmsRoNo || null,
outsdRoNo: outsdRoNo || null
});
// Mark success in export logs
await markRRExportSuccess({
socket,
jobId: rid,
job,
bodyshop,
result,
isEarlyRo: true
});
// Tell FE that early RO was created
socket.emit("rr-early-ro-created", { jobId: rid, dmsRoNo, outsdRoNo });
// Emit result
socket.emit("rr-create-early-ro:result", { jobId: rid, bodyshopId: bodyshop?.id, result });
// ACK with RO details
ack?.({
ok: true,
dmsRoNo,
outsdRoNo,
result,
custNo: String(effectiveCustNo),
createdCustomer,
earlyRoCreated: true
});
} else {
// classify & fail
const tx = result?.statusBlocks?.transaction;
const vendorStatusCode = Number(
result?.roStatus?.statusCode ?? result?.roStatus?.StatusCode ?? tx?.statusCode ?? tx?.StatusCode
);
const vendorMessage =
result?.roStatus?.message ??
result?.roStatus?.Message ??
tx?.message ??
tx?.Message ??
result?.error ??
"RR early RO creation failed";
const cls = classifyRRVendorError({
code: vendorStatusCode,
message: vendorMessage
});
CreateRRLogEvent(socket, "ERROR", `Early RO creation failed`, {
roStatus: result?.roStatus,
statusBlocks: result?.statusBlocks,
classification: cls
});
await insertRRFailedExportLog({
socket,
jobId: rid,
job,
bodyshop,
error: new Error(cls.friendlyMessage || result?.error || "RR early RO creation failed"),
classification: cls,
result
});
socket.emit("export-failed", {
vendor: "rr",
jobId: rid,
error: cls?.friendlyMessage || result?.error || "RR early RO creation failed",
...cls
});
ack?.({
ok: false,
error: cls.friendlyMessage || result?.error || "RR early RO creation failed",
result,
classification: cls
});
}
} catch (error) {
const cls = classifyRRVendorError(error);
CreateRRLogEvent(socket, "ERROR", `Error during RR early RO creation (customer-selected)`, {
error: error.message,
vendorStatusCode: cls.vendorStatusCode,
code: cls.errorCode,
friendly: cls.friendlyMessage,
stack: error.stack,
jobid: rid
});
try {
if (!bodyshop || !job) {
const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
bodyshop = bodyshop || (await getBodyshopForSocket({ bodyshopId, socket }));
job =
job ||
(await redisHelpers.getSessionTransactionData(socket.id, getTransactionType(rid), RRCacheEnums.JobData));
}
} catch {
//
}
await insertRRFailedExportLog({
socket,
jobId: rid,
job,
bodyshop,
error,
classification: cls
});
try {
socket.emit("export-failed", {
vendor: "rr",
jobId: rid,
error: error.message,
...cls
});
socket.emit("rr-user-notice", { jobId: rid, ...cls });
} catch {
//
}
ack?.({ ok: false, error: cls.friendlyMessage || error.message, classification: cls });
}
});
socket.on("rr-export-job", async ({ jobid, jobId, txEnvelope } = {}, ack) => {
const rid = resolveJobId(jobid || jobId, { jobId, jobid }, null);
try {
@@ -422,6 +938,139 @@ const registerRREvents = ({ socket, redisHelpers }) => {
const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
const bodyshop = await getBodyshopForSocket({ bodyshopId, socket });
// Check if this job already has an early RO - if so, use stored IDs and skip customer search
const hasEarlyRO = !!job?.dms_id;
if (hasEarlyRO) {
CreateRRLogEvent(socket, "DEBUG", `{2} Early RO exists - using stored customer/advisor`, {
dms_id: job.dms_id,
dms_customer_id: job.dms_customer_id,
dms_advisor_id: job.dms_advisor_id
});
// Cache the stored customer/advisor IDs for the next step
if (job.dms_customer_id) {
await redisHelpers.setSessionTransactionData(
socket.id,
getTransactionType(rid),
RRCacheEnums.SelectedCustomer,
String(job.dms_customer_id),
defaultRRTTL
);
}
if (job.dms_advisor_id) {
await redisHelpers.setSessionTransactionData(
socket.id,
getTransactionType(rid),
RRCacheEnums.AdvisorNo,
String(job.dms_advisor_id),
defaultRRTTL
);
}
// Emit empty customer list to frontend (won't show modal)
socket.emit("rr-select-customer", []);
// Continue directly with the export by calling the selected customer handler logic inline
// This is essentially the same as if user selected the stored customer
const selectedCustNo = job.dms_customer_id;
if (!selectedCustNo) {
throw new Error("Early RO exists but no customer ID stored");
}
// Continue with ensureRRServiceVehicle and export (same as rr-selected-customer handler)
const { client, opts } = await buildClientAndOpts(bodyshop);
const routing = opts?.routing || client?.opts?.routing || null;
if (!routing?.dealerNumber) throw new Error("ensureRRServiceVehicle: routing.dealerNumber required");
const tx = {
jobData: {
...job,
vin: job?.v_vin
},
txEnvelope
};
const vin = resolveVin({ tx, job });
if (!vin) {
CreateRRLogEvent(socket, "ERROR", "{3.x} No VIN found for ensureRRServiceVehicle", { jobid: rid });
throw new Error("ensureRRServiceVehicle: vin required");
}
const ensured = await ensureRRServiceVehicle({
client,
routing,
bodyshop,
selectedCustomerNo: String(selectedCustNo),
custNo: String(selectedCustNo),
customerNo: String(selectedCustNo),
vin,
job,
socket,
redisHelpers
});
const advisorNo = job.dms_advisor_id || readAdvisorNo({ txEnvelope }, await redisHelpers.getSessionTransactionData(socket.id, getTransactionType(rid), RRCacheEnums.AdvisorNo));
if (!advisorNo) {
throw new Error("Advisor is required (advisorNo).");
}
// UPDATE existing RO with full data
CreateRRLogEvent(socket, "DEBUG", `{4} Updating existing RR RO with full data`, { dmsRoNo: job.dms_id });
const result = await updateRRRepairOrderWithFullData({
bodyshop,
job,
selectedCustomer: { customerNo: String(selectedCustNo), custNo: String(selectedCustNo) },
advisorNo: String(advisorNo),
txEnvelope,
socket,
svId: ensured?.svId || null,
roNo: job.dms_id
});
if (!result?.success) {
throw new Error(result?.roStatus?.message || "Failed to update RR Repair Order");
}
const dmsRoNo = result?.roNo ?? result?.data?.dmsRoNo ?? job.dms_id;
await redisHelpers.setSessionTransactionData(
socket.id,
getTransactionType(rid),
RRCacheEnums.ExportResult,
result || {},
defaultRRTTL
);
await redisHelpers.setSessionTransactionData(
socket.id,
getTransactionType(rid),
RRCacheEnums.PendingRO,
{
outsdRoNo: result?.data?.outsdRoNo ?? job?.ro_number ?? job?.id ?? null,
dmsRoNo,
customerNo: String(selectedCustNo),
advisorNo: String(advisorNo),
vin: job?.v_vin || null,
isUpdate: true
},
defaultRRTTL
);
CreateRRLogEvent(socket, "INFO", `RR Repair Order updated successfully`, {
dmsRoNo,
jobId: rid
});
// For early RO flow, only emit validation-required (not export-job:result)
// since the export is not complete yet - we're just waiting for validation
socket.emit("rr-validation-required", { dmsRoNo, jobId: rid });
return ack?.({ ok: true, skipCustomerSelection: true, dmsRoNo });
}
CreateRRLogEvent(socket, "DEBUG", `{2} Running multi-search (Full Name + VIN)`);
const candidates = await rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers });
@@ -620,17 +1269,59 @@ const registerRREvents = ({ socket, redisHelpers }) => {
defaultRRTTL
);
// CREATE/UPDATE (first step only)
CreateRRLogEvent(socket, "DEBUG", `{4} Performing RR create/update (step 1)`);
const result = await exportJobToRR({
bodyshop,
job,
selectedCustomer: { customerNo: effectiveCustNo, custNo: effectiveCustNo },
advisorNo: String(advisorNo),
txEnvelope,
socket,
svId: ensured?.svId || null
});
// Check if this job already has an early RO created (check job.dms_id)
// If so, we'll use stored customer/advisor IDs and do a full data UPDATE instead of CREATE
const existingDmsId = job?.dms_id || null;
const shouldUpdate = !!existingDmsId;
// When updating an early RO, use stored customer/advisor IDs
let finalEffectiveCustNo = effectiveCustNo;
let finalAdvisorNo = advisorNo;
if (shouldUpdate && job?.dms_customer_id) {
CreateRRLogEvent(socket, "DEBUG", `Using stored customer ID from early RO`, {
storedCustomerId: job.dms_customer_id,
originalCustomerId: effectiveCustNo
});
finalEffectiveCustNo = String(job.dms_customer_id);
}
if (shouldUpdate && job?.dms_advisor_id) {
CreateRRLogEvent(socket, "DEBUG", `Using stored advisor ID from early RO`, {
storedAdvisorId: job.dms_advisor_id,
originalAdvisorId: advisorNo
});
finalAdvisorNo = String(job.dms_advisor_id);
}
let result;
if (shouldUpdate) {
// UPDATE existing RO with full data
CreateRRLogEvent(socket, "DEBUG", `{4} Updating existing RR RO with full data`, { dmsRoNo: existingDmsId });
result = await updateRRRepairOrderWithFullData({
bodyshop,
job,
selectedCustomer: { customerNo: finalEffectiveCustNo, custNo: finalEffectiveCustNo },
advisorNo: String(finalAdvisorNo),
txEnvelope,
socket,
svId: ensured?.svId || null,
roNo: existingDmsId
});
} else {
// CREATE new RO (legacy flow - full data on first create)
CreateRRLogEvent(socket, "DEBUG", `{4} Performing RR create (step 1 - full data)`);
result = await exportJobToRR({
bodyshop,
job,
selectedCustomer: { customerNo: finalEffectiveCustNo, custNo: finalEffectiveCustNo },
advisorNo: String(finalAdvisorNo),
txEnvelope,
socket,
svId: ensured?.svId || null
});
}
// Cache raw export result + pending RO number for finalize
await redisHelpers.setSessionTransactionData(

View File

@@ -8,6 +8,12 @@ const client = require("../graphql-client/graphql-client").client;
*/
const BODYSHOP_CACHE_TTL = 3600; // 1 hour
/**
* Chatter API token cache TTL in seconds
* @type {number}
*/
const CHATTER_TOKEN_CACHE_TTL = 3600; // 1 hour
/**
* Generate a cache key for a bodyshop
* @param bodyshopId
@@ -15,6 +21,13 @@ const BODYSHOP_CACHE_TTL = 3600; // 1 hour
*/
const getBodyshopCacheKey = (bodyshopId) => `bodyshop-cache:${bodyshopId}`;
/**
* Generate a cache key for a Chatter API token
* @param companyId
* @returns {`chatter-token:${string}`}
*/
const getChatterTokenCacheKey = (companyId) => `chatter-token:${companyId}`;
/**
* Generate a cache key for a user socket mapping
* @param email
@@ -373,9 +386,53 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
*/
const getProviderCache = (ns, field) => getSessionData(`${ns}:provider`, field);
/**
* Get Chatter API token from Redis cache
* @param companyId
* @returns {Promise<string|null>}
*/
const getChatterToken = async (companyId) => {
const key = getChatterTokenCacheKey(companyId);
try {
const token = await pubClient.get(key);
return token;
} catch (error) {
logger.log("get-chatter-token-from-redis", "ERROR", "redis", null, {
companyId,
error: error.message
});
return null;
}
};
/**
* Set Chatter API token in Redis cache
* @param companyId
* @param token
* @returns {Promise<void>}
*/
const setChatterToken = async (companyId, token) => {
const key = getChatterTokenCacheKey(companyId);
try {
await pubClient.set(key, token);
await pubClient.expire(key, CHATTER_TOKEN_CACHE_TTL);
devDebugLogger("chatter-token-cache-set", {
companyId,
action: "Token cached"
});
} catch (error) {
logger.log("set-chatter-token-in-redis", "ERROR", "redis", null, {
companyId,
error: error.message
});
throw error;
}
};
const api = {
getUserSocketMappingKey,
getBodyshopCacheKey,
getChatterTokenCacheKey,
setSessionData,
getSessionData,
clearSessionData,
@@ -390,7 +447,9 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
getSessionTransactionData,
clearSessionTransactionData,
setProviderCache,
getProviderCache
getProviderCache,
getChatterToken,
setChatterToken
};
Object.assign(module.exports, api);