Compare commits

...

72 Commits

Author SHA1 Message Date
Patrick Fic
51fca7a63c IO-3515 Add shopname to bill ai feedback. 2026-03-20 09:13:01 -07:00
Patrick Fic
5812d53efc IO-3515 Improved feedback layout. 2026-03-19 14:54:40 -07:00
Patrick Fic
b2231007b6 IO-3515 Bill OCR Feedback. 2026-03-19 14:47:22 -07:00
Dave Richer
8af8c8039c Merged in release/2026-03-13 (pull request #3131)
Release/2026-03-13 into master-AIO - IO-3571, IO-3582, IO-3584, IO-3590, IO-3592, IO-3596, IO-3600, IO-3601, IO-3603, IO-3604, IO-3605, IO-3606, IO-3607, IO-3610
2026-03-14 00:58:46 +00:00
Dave
3a1d10b0d1 Merge remote-tracking branch 'origin/feature/IO-3610-Export-Log-DMS-Bug' into release/2026-03-13 2026-03-12 19:49:56 -04:00
Allan Carr
e6071709be IO-3610 Export Log DMS Bug
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-12 16:28:14 -07:00
Allan Carr
c95c11fd0e Merged in hotfix/2026-03-12 (pull request #3125)
IO-3585 saleClassValue fix
2026-03-12 19:01:16 +00:00
Allan Carr
1351fbb814 Merged in feature/IO-3585-Fortellis-Insert-and-Update-Vehicle (pull request #3123)
IO-3585 saleClassValue fix
2026-03-12 18:58:35 +00:00
Allan Carr
dcd3a078ef IO-3585 saleClassValue fix
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-12 11:58:40 -07:00
Allan Carr
bb8e140f6e Merged in feature/IO-3585-Fortellis-Insert-and-Update-Vehicle (pull request #3122)
IO-3585 saleClassValue fix
2026-03-12 18:57:07 +00:00
Dave Richer
bf11e10676 Merged in hotfix/2026-03-12 (pull request #3119)
hotfix/2026-03-12 - Be more specific on CDK error passing, resolve circular dependency
2026-03-12 16:38:15 +00:00
Dave Richer
92e6bdf2a2 Merged in hotfix/2026-03-12 (pull request #3117)
hotfix/2026-03-12 - Be more specific on CDK error passing, resolve circular dependency
2026-03-12 16:37:23 +00:00
Allan Carr
a02e336d73 Merged in feature/IO-3584-Duplicate-Job-with-Full-Rates (pull request #3116)
IO-3584 Duplicate Job with Full Rates

Approved-by: Dave Richer
2026-03-12 16:34:40 +00:00
Dave
7ec8a73c30 hotfix/2026-03-12 - Be more specific on CDK error passing, resolve circular dependency 2026-03-12 12:33:54 -04:00
Allan Carr
e669c19b98 IO-3584 Duplicate Job with Full Rates
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-11 18:48:23 -07:00
Allan Carr
5c55c0c74b Merged in feature/IO-3582-Add-Return-From-Invoice-to-Order-Table (pull request #3114)
IO-3582 Add Return From Inv to Parts Return Table

Approved-by: Dave Richer
2026-03-11 13:52:43 +00:00
Allan Carr
f1f705903a IO-3582 Add Return From Inv to Parts Return Table
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-10 20:02:32 -07:00
Allan Carr
6551be2d92 Merged in feature/IO-3606-Tech-Console-Ticket-Date (pull request #3111)
IO-3606 Tech Console Job Clock In Ticket Date

Approved-by: Dave Richer
2026-03-10 15:32:36 +00:00
Allan Carr
48e59fe849 Merged in feature/IO-3607-Employee-Drop-Down-Inactive (pull request #3110)
IO-3607 Employee Drop Down Inactive filter

Approved-by: Dave Richer
2026-03-10 15:32:14 +00:00
Allan Carr
7991192496 Merged in feature/IO-3592-WIP-Summary-Reports (pull request #3112)
IO-3592 WIP Summary Reports

Approved-by: Dave Richer
2026-03-10 15:31:57 +00:00
Allan Carr
05cd60c2a1 IO-3592 WIP Summary Reports
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-09 17:57:55 -07:00
Allan Carr
26fc76a767 IO-3606 Tech Console Job Clock In Ticket Date
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-09 17:42:13 -07:00
Allan Carr
49816d5d43 IO-3607 Employee Drop Down Inactive filter
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-09 16:28:10 -07:00
Dave Richer
b9b3e2c2aa Merged in hotfix/2026-03-09 (pull request #3109)
hotfix/2026-03-09 - Eula
2026-03-09 17:00:49 +00:00
Allan Carr
e3c02f94f1 Merged in feature/IO-3603-Production-Board-Note-Autofocus (pull request #3102)
IO-3603 Production Board Note Autofocus

Approved-by: Dave Richer
2026-03-09 16:59:58 +00:00
Allan Carr
490dd662d5 Merged in feature/IO-3571-Create-Job-Done-Loading (pull request #3105)
IO-3571 Create Job Done Button Loading

Approved-by: Dave Richer
2026-03-09 16:59:22 +00:00
Dave
8d00fc29d1 feature/IO-3603-Production-Board-Note-Autofocus - Fix 2026-03-09 12:59:00 -04:00
Dave
784378a999 feature/IO-3571-Create-Job-Done-Loading - Fix set is submitting 2026-03-09 12:53:59 -04:00
Allan Carr
f04f48f593 Merged in feature/IO-3605-Material-Threshold-Calculations (pull request #3103)
IO-3605 Material Threshold Calculations

Approved-by: Dave Richer
2026-03-09 16:36:35 +00:00
Allan Carr
721e9bc464 Merged in feature/IO-3590-Admin-Save-Buttons (pull request #3104)
IO-3590 Admin Save Buttons

Approved-by: Dave Richer
2026-03-09 16:35:55 +00:00
Dave Richer
76c828a1c9 Merged in hotfix/2026-03-09 (pull request #3106)
hotfix/2026-03-09 - Eula
2026-03-09 16:33:45 +00:00
Dave
7e5363f911 hotfix/2026-03-09 - Eula 2026-03-09 12:33:20 -04:00
Allan Carr
0d502d4dd4 IO-3571 Create Job Done Button Loading
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-06 18:31:22 -08:00
Allan Carr
f5b16394f9 IO-3590 Admin Save Buttons
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-06 18:16:36 -08:00
Allan Carr
7132465945 IO-3605 Material Threshold Calculations
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-06 17:56:21 -08:00
Allan Carr
a873a2573a IO-3603 Production Board Note Autofocus
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-06 15:32:06 -08:00
Allan Carr
ff24db6561 Merged in feature/IO-3596-Manual-Line-Lock-Down (pull request #3100)
IO-3596 Manual Line Lock Down

Approved-by: Dave Richer
2026-03-06 22:23:03 +00:00
Allan Carr
da26954c3b IO-3596 Manual Line Lock Down
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-06 14:21:00 -08:00
Allan Carr
6991cf60e5 Merged in feature/IO-3604-Tech-Job-Drawer (pull request #3098)
IO-3604 Tech Job Drawer
2026-03-06 20:41:23 +00:00
Allan Carr
818aedf04f IO-3604 Tech Job Drawer
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-06 12:43:12 -08:00
Allan Carr
1cb6834207 Merged in feature/IO-3600-Job-Line-Close-Select-Box-Filter (pull request #3096)
IO-3600 Job Line Close Select Box Filter

Approved-by: Dave Richer
2026-03-06 19:16:18 +00:00
Allan Carr
8577929bd4 IO-3600 Job Line Close Select Box Filter
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-06 11:17:27 -08:00
Allan Carr
f44121e06b Merged in feature/IO-3601-QBO-Logging (pull request #3095)
IO-3601 Additional QBO Logging

Approved-by: Dave Richer
2026-03-06 18:32:16 +00:00
Allan Carr
faf9fb75c5 IO-3601 Additional QBO Logging
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-05 18:01:53 -08:00
Dave Richer
8980d3716b Merged in release/2026-03-13 (pull request #3092)
release/2026-02-27 - Final RR debug fix [FRONT END NOT REQUIRED]

Approved-by: Allan Carr
2026-03-04 20:31:22 +00:00
Dave Richer
764ec5f8f9 Merged in release/2026-02-27 (pull request #3091)
release/2026-02-27 - Final RR debug fix
2026-03-04 20:20:49 +00:00
Dave
a7a7551dae release/2026-02-27 - Final RR debug fix 2026-03-04 15:17:56 -05:00
Dave Richer
571536a7ec Merged in master-AIO (pull request #3089)
release/2026-02-27 - bump
2026-03-04 19:28:47 +00:00
Dave Richer
20e56fff6a Merged in release/2026-02-27 (pull request #3088)
release/2026-02-27 - bump
2026-03-04 19:28:21 +00:00
Dave
8f132ca14d release/2026-02-27 - bump 2026-03-04 14:27:23 -05:00
Dave Richer
99c002dac1 Merged in master-AIO (pull request #3086)
Master AIO
2026-03-04 19:24:37 +00:00
Dave Richer
0cd30ccdec Merged in release/2026-02-27 (pull request #3085)
Release/2026 02 27
2026-03-04 17:45:15 +00:00
Patrick Fic
acd69276a5 Merged in release/revert-revert-pr-3080 (pull request #3083)
Revert "Revert "Release/2026 02 27 (pull request #3070)" (pull request #3080)"
2026-03-04 17:41:44 +00:00
Patrick Fic
faf5878bdf Revert "Revert "Release/2026 02 27 (pull request #3070)" (pull request #3080)" 2026-03-04 17:41:10 +00:00
Dave
f56a540b2f release/2026-02-27 - Fix Time ticket issue, add additional logging around reynolds 2026-03-04 12:21:29 -05:00
Dave
e251e5f8f6 release/2026-02-27 - Disable Responsive Design 2026-03-04 11:38:33 -05:00
Patrick Fic
5a55798d2d Merged in release/revert-pr-3070-2026-03-04 (pull request #3080)
Revert "Release/2026 02 27 (pull request #3070)"
2026-03-04 16:20:15 +00:00
Patrick Fic
c9e41ba72a Revert "Release/2026 02 27 (pull request #3070)" 2026-03-04 16:18:44 +00:00
Dave Richer
522f2b9e26 Merged in release/2026-02-27 (pull request #3070)
Release/2026 02 27
2026-03-04 01:41:53 +00:00
Allan Carr
be9267ddd4 Merged in feature/IO-3594-Kaizen-Datapump-Enhancement (pull request #3076)
Feature/IO-3594 Kaizen Datapump Enhancement

Approved-by: Dave Richer
2026-03-04 00:51:11 +00:00
Patrick Fic
e4a79b51c7 Merged in feature/IO-3515-ocr-bill-posting (pull request #3077)
Feature/IO-3515 ocr bill posting
2026-03-03 22:56:52 +00:00
Patrick Fic
47a9a963fa IO-3515 Minor improvements to Bill AI. 2026-03-03 14:56:28 -08:00
Allan Carr
f3c7a831a1 IO-3594 Kaizen Datapump Enhancement
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-03 13:18:16 -08:00
Allan Carr
6ac9310e81 IO-3594 Kaizen Datapump Enhancement
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-03 13:17:56 -08:00
Dave
b91e65be0e release/2026-02-27 - Add gating 2026-03-03 15:25:13 -05:00
Dave
3f2358e30c Merge remote-tracking branch 'origin/hotfix/2026-03-03' into release/2026-02-27 2026-03-03 13:08:31 -05:00
Dave Richer
ce02d90c3c Merged in hotfix/2026-03-03-RR-logging-Posting-Enhancements (pull request #3072)
hotfix/2026-03-03-RR-logging-Posting-Enhancements - Implement
2026-03-03 18:05:06 +00:00
Allan Carr
95a71bea6e Merged in feature/IO-3585-Fortellis-Insert-and-Update-Vehicle (pull request #3071)
IO-3585 Fortellis Insert and Update Vehicle Info

Approved-by: Dave Richer
2026-03-03 18:03:41 +00:00
Dave
3b27120d77 hotfix/2026-03-03-RR-logging-Posting-Enhancements - Implement 2026-03-03 13:03:02 -05:00
Dave Richer
f350163056 Merged in feature/IO-3554-Form-Row-Layout (pull request #3068)
feature/IO-3554-Form-Row-Layout - dial in tables
2026-03-02 17:00:21 +00:00
Dave Richer
57cfecb7b8 Merged in feature/IO-3554-Form-Row-Layout (pull request #3066)
feature/IO-3554-Form-Row-Layout - Modify how truncation works on responsive tables.
2026-03-02 16:29:45 +00:00
Patrick Fic
5f8a08b0a7 IO-3515 Limit logging. 2026-02-23 11:55:22 -08:00
62 changed files with 3042 additions and 648 deletions

View File

@@ -2696,6 +2696,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>oem_partno</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>quantity</name>
<definition_loaded>false</definition_loaded>
@@ -3684,6 +3705,48 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>feedback_placeholder</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>feedback_prompt</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>generic_failure</name>
<definition_loaded>false</definition_loaded>
@@ -3831,6 +3894,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>submit_feedback</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
<concept_node>
@@ -8641,6 +8725,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>manual-line</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>partsqueue</name>
<definition_loaded>false</definition_loaded>
@@ -17816,6 +17921,468 @@
<folder_node>
<name>labels</name>
<children>
<concept_node>
<name>banner_message</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>banner_status_connected</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>banner_status_disconnected</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>clear_logs</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>collapse_all</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>color_json</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>copied</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>copy</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>copy_request</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>copy_response</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>details</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>expand_all</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>hide_details</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>log_level</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>plain_json</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>provider_cdk</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>provider_dms</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>provider_fortellis</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>provider_pbs</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>provider_reynolds</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>reconnect</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>reconnected_export_service</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>refreshallocations</name>
<definition_loaded>false</definition_loaded>
@@ -17837,6 +18404,153 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>request_xml</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>response_xml</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>rr_validation_message</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>rr_validation_notice_description</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>rr_validation_notice_title</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>transport_ws</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>transport_wss</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
</children>
@@ -20590,6 +21304,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>done</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>download</name>
<definition_loaded>false</definition_loaded>
@@ -23009,6 +23744,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>validationerror</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>view</name>
<definition_loaded>false</definition_loaded>
@@ -23423,6 +24179,27 @@
<folder_node>
<name>validation</name>
<children>
<concept_node>
<name>array</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>dateRangeExceeded</name>
<definition_loaded>false</definition_loaded>
@@ -57452,6 +58229,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>work_in_progress_labour_summary</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>work_in_progress_payables</name>
<definition_loaded>false</definition_loaded>
@@ -57473,6 +58271,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>work_in_progress_payables_summary</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
</children>

View File

@@ -0,0 +1,184 @@
import { ApolloProvider } from "@apollo/client/react";
import * as Sentry from "@sentry/react";
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
import { ConfigProvider, Grid } from "antd";
import enLocale from "antd/es/locale/en_US";
import { useEffect, useMemo } from "react";
import { CookiesProvider } from "react-cookie";
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
import { setDarkMode } from "../redux/application/application.actions";
import { selectDarkMode } from "../redux/application/application.selectors";
import { selectCurrentUser } from "../redux/user/user.selectors.js";
import { signOutStart } from "../redux/user/user.actions";
import client from "../utils/GraphQLClient";
import App from "./App";
import getTheme from "./themeProvider";
// Base Split configuration
const config = {
core: {
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
key: "anon"
}
};
function SplitClientProvider({ children }) {
const imexshopid = useSelector((state) => state.user.imexshopid);
const splitClient = useSplitClient({ key: imexshopid || "anon" });
useEffect(() => {
if (import.meta.env.DEV && splitClient && imexshopid) {
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
}
}, [splitClient, imexshopid]);
return children;
}
function AppContainer() {
const { t } = useTranslation();
const dispatch = useDispatch();
const currentUser = useSelector(selectCurrentUser);
const isDarkMode = useSelector(selectDarkMode);
const screens = Grid.useBreakpoint();
const isPhone = !screens.md;
const isUltraWide = Boolean(screens.xxxl);
const theme = useMemo(() => {
const baseTheme = getTheme(isDarkMode);
return {
...baseTheme,
token: {
...(baseTheme.token || {}),
screenXXXL: 2160
},
components: {
...(baseTheme.components || {}),
Table: {
...(baseTheme.components?.Table || {}),
cellFontSizeSM: isPhone ? 12 : 13,
cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14,
cellFontSize: isUltraWide ? 15 : 14,
cellPaddingInlineSM: isPhone ? 8 : 10,
cellPaddingInlineMD: isPhone ? 10 : 14,
cellPaddingInline: isUltraWide ? 20 : 16,
cellPaddingBlockSM: isPhone ? 8 : 10,
cellPaddingBlockMD: isPhone ? 10 : 12,
cellPaddingBlock: isUltraWide ? 14 : 12,
selectionColumnWidth: isPhone ? 44 : 52
}
}
};
}, [isDarkMode, isPhone, isUltraWide]);
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []);
const antdPagination = useMemo(
() => ({
showSizeChanger: !isPhone,
totalBoundaryShowSizeChanger: 100
}),
[isPhone]
);
const antdForm = useMemo(
() => ({
validateMessages: {
required: t("general.validation.required", { label: "${label}" })
}
}),
[t]
);
// Global seamless logout listener with redirect to /signin
useEffect(() => {
const handleSeamlessLogout = (event) => {
if (event.data?.type !== "seamlessLogoutRequest") return;
// Only accept messages from the parent window
if (event.source !== window.parent) return;
const targetOrigin = event.origin || "*";
if (currentUser?.authorized !== true) {
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin);
return;
}
dispatch(signOutStart());
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin);
};
window.addEventListener("message", handleSeamlessLogout);
return () => {
window.removeEventListener("message", handleSeamlessLogout);
};
}, [dispatch, currentUser?.authorized]);
// Update data-theme attribute (no cleanup to avoid transient style churn)
useEffect(() => {
document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
}, [isDarkMode]);
// Sync darkMode with localStorage
useEffect(() => {
const uid = currentUser?.uid;
if (!uid) {
dispatch(setDarkMode(false));
return;
}
const key = `dark-mode-${uid}`;
const raw = localStorage.getItem(key);
if (raw == null) {
dispatch(setDarkMode(false));
return;
}
try {
dispatch(setDarkMode(Boolean(JSON.parse(raw))));
} catch {
dispatch(setDarkMode(false));
}
}, [currentUser?.uid, dispatch]);
// Persist darkMode
useEffect(() => {
const uid = currentUser?.uid;
if (!uid) return;
localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode));
}, [isDarkMode, currentUser?.uid]);
return (
<CookiesProvider>
<ApolloProvider client={client}>
<ConfigProvider
input={antdInput}
locale={enLocale}
theme={theme}
form={antdForm}
table={antdTable}
pagination={antdPagination}
componentSize={isPhone ? "small" : isUltraWide ? "large" : "middle"}
popupOverflow="viewport"
>
<GlobalLoadingBar />
<SplitFactoryProvider config={config}>
<SplitClientProvider>
<App />
</SplitClientProvider>
</SplitFactoryProvider>
</ConfigProvider>
</ApolloProvider>
</CookiesProvider>
);
}
export default Sentry.withProfiler(AppContainer);

View File

@@ -1,7 +1,7 @@
import { ApolloProvider } from "@apollo/client/react";
import * as Sentry from "@sentry/react";
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
import { ConfigProvider, Grid } from "antd";
import { ConfigProvider } from "antd";
import enLocale from "antd/es/locale/en_US";
import { useEffect, useMemo } from "react";
import { CookiesProvider } from "react-cookie";
@@ -43,47 +43,10 @@ function AppContainer() {
const currentUser = useSelector(selectCurrentUser);
const isDarkMode = useSelector(selectDarkMode);
const screens = Grid.useBreakpoint();
const isPhone = !screens.md;
const isUltraWide = Boolean(screens.xxxl);
const theme = useMemo(() => {
const baseTheme = getTheme(isDarkMode);
return {
...baseTheme,
token: {
...(baseTheme.token || {}),
screenXXXL: 2160
},
components: {
...(baseTheme.components || {}),
Table: {
...(baseTheme.components?.Table || {}),
cellFontSizeSM: isPhone ? 12 : 13,
cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14,
cellFontSize: isUltraWide ? 15 : 14,
cellPaddingInlineSM: isPhone ? 8 : 10,
cellPaddingInlineMD: isPhone ? 10 : 14,
cellPaddingInline: isUltraWide ? 20 : 16,
cellPaddingBlockSM: isPhone ? 8 : 10,
cellPaddingBlockMD: isPhone ? 10 : 12,
cellPaddingBlock: isUltraWide ? 14 : 12,
selectionColumnWidth: isPhone ? 44 : 52
}
}
};
}, [isDarkMode, isPhone, isUltraWide]);
const theme = useMemo(() => getTheme(isDarkMode), [isDarkMode]);
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []);
const antdPagination = useMemo(
() => ({
showSizeChanger: !isPhone,
totalBoundaryShowSizeChanger: 100
}),
[isPhone]
);
const antdForm = useMemo(
() => ({
@@ -159,16 +122,7 @@ function AppContainer() {
return (
<CookiesProvider>
<ApolloProvider client={client}>
<ConfigProvider
input={antdInput}
locale={enLocale}
theme={theme}
form={antdForm}
table={antdTable}
pagination={antdPagination}
componentSize={isPhone ? "small" : isUltraWide ? "large" : "middle"}
popupOverflow="viewport"
>
<ConfigProvider input={antdInput} locale={enLocale} theme={theme} form={antdForm}>
<GlobalLoadingBar />
<SplitFactoryProvider config={config}>
<SplitClientProvider>

View File

@@ -0,0 +1,184 @@
import { ApolloProvider } from "@apollo/client/react";
import * as Sentry from "@sentry/react";
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
import { ConfigProvider, Grid } from "antd";
import enLocale from "antd/es/locale/en_US";
import { useEffect, useMemo } from "react";
import { CookiesProvider } from "react-cookie";
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
import { setDarkMode } from "../redux/application/application.actions";
import { selectDarkMode } from "../redux/application/application.selectors";
import { selectCurrentUser } from "../redux/user/user.selectors.js";
import { signOutStart } from "../redux/user/user.actions";
import client from "../utils/GraphQLClient";
import App from "./App";
import getTheme from "./themeProvider";
// Base Split configuration
const config = {
core: {
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
key: "anon"
}
};
function SplitClientProvider({ children }) {
const imexshopid = useSelector((state) => state.user.imexshopid);
const splitClient = useSplitClient({ key: imexshopid || "anon" });
useEffect(() => {
if (import.meta.env.DEV && splitClient && imexshopid) {
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
}
}, [splitClient, imexshopid]);
return children;
}
function AppContainer() {
const { t } = useTranslation();
const dispatch = useDispatch();
const currentUser = useSelector(selectCurrentUser);
const isDarkMode = useSelector(selectDarkMode);
const screens = Grid.useBreakpoint();
const isPhone = !screens.md;
const isUltraWide = Boolean(screens.xxxl);
const theme = useMemo(() => {
const baseTheme = getTheme(isDarkMode);
return {
...baseTheme,
token: {
...(baseTheme.token || {}),
screenXXXL: 2160
},
components: {
...(baseTheme.components || {}),
Table: {
...(baseTheme.components?.Table || {}),
cellFontSizeSM: isPhone ? 12 : 13,
cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14,
cellFontSize: isUltraWide ? 15 : 14,
cellPaddingInlineSM: isPhone ? 8 : 10,
cellPaddingInlineMD: isPhone ? 10 : 14,
cellPaddingInline: isUltraWide ? 20 : 16,
cellPaddingBlockSM: isPhone ? 8 : 10,
cellPaddingBlockMD: isPhone ? 10 : 12,
cellPaddingBlock: isUltraWide ? 14 : 12,
selectionColumnWidth: isPhone ? 44 : 52
}
}
};
}, [isDarkMode, isPhone, isUltraWide]);
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []);
const antdPagination = useMemo(
() => ({
showSizeChanger: !isPhone,
totalBoundaryShowSizeChanger: 100
}),
[isPhone]
);
const antdForm = useMemo(
() => ({
validateMessages: {
required: t("general.validation.required", { label: "${label}" })
}
}),
[t]
);
// Global seamless logout listener with redirect to /signin
useEffect(() => {
const handleSeamlessLogout = (event) => {
if (event.data?.type !== "seamlessLogoutRequest") return;
// Only accept messages from the parent window
if (event.source !== window.parent) return;
const targetOrigin = event.origin || "*";
if (currentUser?.authorized !== true) {
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin);
return;
}
dispatch(signOutStart());
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin);
};
window.addEventListener("message", handleSeamlessLogout);
return () => {
window.removeEventListener("message", handleSeamlessLogout);
};
}, [dispatch, currentUser?.authorized]);
// Update data-theme attribute (no cleanup to avoid transient style churn)
useEffect(() => {
document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
}, [isDarkMode]);
// Sync darkMode with localStorage
useEffect(() => {
const uid = currentUser?.uid;
if (!uid) {
dispatch(setDarkMode(false));
return;
}
const key = `dark-mode-${uid}`;
const raw = localStorage.getItem(key);
if (raw == null) {
dispatch(setDarkMode(false));
return;
}
try {
dispatch(setDarkMode(Boolean(JSON.parse(raw))));
} catch {
dispatch(setDarkMode(false));
}
}, [currentUser?.uid, dispatch]);
// Persist darkMode
useEffect(() => {
const uid = currentUser?.uid;
if (!uid) return;
localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode));
}, [isDarkMode, currentUser?.uid]);
return (
<CookiesProvider>
<ApolloProvider client={client}>
<ConfigProvider
input={antdInput}
locale={enLocale}
theme={theme}
form={antdForm}
table={antdTable}
pagination={antdPagination}
componentSize={isPhone ? "small" : isUltraWide ? "large" : "middle"}
popupOverflow="viewport"
>
<GlobalLoadingBar />
<SplitFactoryProvider config={config}>
<SplitClientProvider>
<App />
</SplitClientProvider>
</SplitFactoryProvider>
</ConfigProvider>
</ApolloProvider>
</CookiesProvider>
);
}
export default Sentry.withProfiler(AppContainer);

View File

@@ -443,38 +443,62 @@
flex-direction: column;
}
/* DMS top panels: prevent card/table overflow into adjacent column at desktop+zoom */
.dms-top-panel-col {
min-width: 0;
}
.dms-top-panel-col > .ant-card {
width: 100%;
min-width: 0;
max-width: 100%;
}
.dms-top-panel-col > .ant-card .ant-card-body {
min-width: 0;
max-width: 100%;
}
.dms-top-panel-col .ant-table-wrapper,
.dms-top-panel-col .ant-tabs,
.dms-top-panel-col .ant-tabs-content,
.dms-top-panel-col .ant-tabs-tabpane {
min-width: 0;
max-width: 100%;
}
//.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;
}
///* 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;
//}
.global-search-autocomplete-fix {

View File

@@ -0,0 +1,118 @@
import { DislikeOutlined, LikeOutlined } from "@ant-design/icons";
import { Button, Form, Input, Radio, Space } from "antd";
import axios from "axios";
import { useState } from "react";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { createStructuredSelector } from "reselect";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
function BillAiFeedback({ billForm, rawAIData, bodyshop }) {
const { t } = useTranslation();
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const notification = useNotification();
//Need to sanitize becuase we pass as form data to include the attachment.
const sanitizeBillFormValues = (value) => {
const seen = new WeakSet();
return JSON.stringify(
value,
(key, v) => {
if (key === "originFileObj") return undefined;
if (key === "thumbUrl") return undefined;
if (key === "preview") return undefined;
if (typeof v === "function") return undefined;
if (v && typeof v === "object") {
if (seen.has(v)) return "[Circular]";
seen.add(v);
}
return v;
},
0
);
};
const getAttachmentFromBillFormUpload = () => {
const uploads = billForm?.getFieldValue?.("upload") || [];
const files = uploads.map((u) => u?.originFileObj).filter(Boolean);
return (
files.find((f) => f?.type === "application/pdf") ||
files.find((f) => isString(f?.name) && f.name.toLowerCase().endsWith(".pdf")) ||
files[0] ||
null
);
};
const submitFeedback = async ({ rating, comments }) => {
setSubmitting(true);
try {
const billFormValues = billForm.getFieldsValue(true);
const formData = new FormData();
formData.append("rating", rating);
formData.append("comments", comments || "");
formData.append("billFormValues", sanitizeBillFormValues(billFormValues));
formData.append("rawAIData", sanitizeBillFormValues(rawAIData));
formData.append("shopname", bodyshop?.shopname || "");
const attachmentFile = getAttachmentFromBillFormUpload();
if (attachmentFile) {
formData.append("billPdf", attachmentFile, attachmentFile.name || "bill.pdf");
}
await axios.post("/ai/bill-feedback", formData);
notification.success({
title: "Thanks — feedback submitted"
});
form.resetFields();
} catch (error) {
notification.error({
title: "Failed to submit feedback",
description: error?.response?.data?.message || error?.message
});
} finally {
setSubmitting(false);
}
};
const isString = (v) => typeof v === "string";
return (
<Form form={form} onFinish={submitFeedback} requiredMark={false}>
<Space wrap align="top" size="small">
<Form.Item name="rating" label={t("bills.labels.ai.feedback_prompt")} rules={[{ required: true }]}>
<Radio.Group optionType="button" buttonStyle="solid">
<Radio.Button value="up">
<LikeOutlined />
</Radio.Button>
<Radio.Button value="down">
<DislikeOutlined />
</Radio.Button>
</Radio.Group>
</Form.Item>
<Space wrap size="small" orientation="vertical">
<Form.Item name="comments">
<Input.TextArea
rows={3}
style={{ minWidth: "400px" }}
placeholder={t("bills.labels.ai.feedback_placeholder")}
/>
</Form.Item>
<Button onClick={() => form.submit()} loading={submitting} disabled={submitting}>
{t("bills.labels.ai.submit_feedback")}
</Button>
</Space>
</Space>
</Form>
);
}
export default connect(mapStateToProps, null)(BillAiFeedback);

View File

@@ -23,7 +23,8 @@ function BillEnterAiScan({
fileInputRef,
scanLoading,
setScanLoading,
setIsAiScan
setIsAiScan,
setRawAIData
}) {
const notification = useNotification();
const { t } = useTranslation();
@@ -57,6 +58,7 @@ function BillEnterAiScan({
}
setScanLoading(false);
setRawAIData(data.data);
// Update form with the extracted data
if (data?.data?.billForm) {
form.setFieldsValue(data.data.billForm);
@@ -108,7 +110,7 @@ function BillEnterAiScan({
setIsAiScan(true);
const formdata = new FormData();
formdata.append("billScan", file);
formdata.append("jobid", billEnterModal.context.job?.id);
formdata.append("jobid", form.getFieldValue("jobid") || billEnterModal.context.job?.id);
formdata.append("bodyshopid", bodyshop.id);
formdata.append("partsorderid", billEnterModal.context.parts_order?.id);
@@ -147,6 +149,7 @@ function BillEnterAiScan({
setScanLoading(false);
form.setFieldsValue(data.data.billForm);
setRawAIData(data.data);
await form.validateFields(["billlines"], { recursive: true });
notification.success({

View File

@@ -1,6 +1,6 @@
import { useApolloClient, useMutation } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Checkbox, Form, Modal, Space } from "antd";
import { Button, Checkbox, Divider, Form, Modal, Space } from "antd";
import _ from "lodash";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -28,6 +28,7 @@ import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility";
import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility";
import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility";
import { handleUpload } from "../documents-upload/documents-upload.utility";
import BillAiFeedback from "../bill-ai-feedback/bill-ai-feedback.component.jsx";
const mapStateToProps = createStructuredSelector({
billEnterModal: selectBillEnterModal,
@@ -53,6 +54,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
const [loading, setLoading] = useState(false);
const [scanLoading, setScanLoading] = useState(false);
const [isAiScan, setIsAiScan] = useState(false);
const [rawAIData, setRawAIData] = useState(null);
const client = useApolloClient();
const [generateLabel, setGenerateLabel] = useLocalStorage("enter_bill_generate_label", false);
const notification = useNotification();
@@ -387,6 +389,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
billlines: []
});
setIsAiScan(false);
setRawAIData(null);
// form.resetFields();
} else {
toggleModalVisible();
@@ -404,6 +407,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
}
setScanLoading(false);
setIsAiScan(false);
setRawAIData(null);
toggleModalVisible();
}
};
@@ -429,6 +433,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
}
setScanLoading(false);
setIsAiScan(false);
setRawAIData(null);
}
}, [billEnterModal.open, form, formValues]);
@@ -456,6 +461,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
scanLoading={scanLoading}
setScanLoading={setScanLoading}
setIsAiScan={setIsAiScan}
setRawAIData={setRawAIData}
/>
)}
</Space>
@@ -471,26 +477,34 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
setLoading(false);
}}
footer={
<Space>
<Checkbox checked={generateLabel} onChange={(e) => setGenerateLabel(e.target.checked)}>
{t("bills.labels.generatepartslabel")}
</Checkbox>
<Button onClick={handleCancel}>{t("general.actions.cancel")}</Button>
<Button loading={loading} onClick={() => form.submit()} id="save-bill-enter-modal">
{t("general.actions.save")}
</Button>
{billEnterModal.context && billEnterModal.context.id ? null : (
<Button
type="primary"
loading={loading}
onClick={() => {
setEnterAgain(true);
}}
id="save-and-new-bill-enter-modal"
>
{t("general.actions.saveandnew")}
</Button>
<Space orientation="vertical">
{isAiScan && (
<>
<BillAiFeedback billForm={form} rawAIData={rawAIData} />
<Divider orientation="horizontal" />
</>
)}
<Space wrap align="top">
<Checkbox checked={generateLabel} onChange={(e) => setGenerateLabel(e.target.checked)}>
{t("bills.labels.generatepartslabel")}
</Checkbox>
<Button onClick={handleCancel}>{t("general.actions.cancel")}</Button>
<Button loading={loading} onClick={() => form.submit()} id="save-bill-enter-modal">
{t("general.actions.save")}
</Button>
{billEnterModal.context && billEnterModal.context.id ? null : (
<Button
type="primary"
loading={loading}
onClick={() => {
setEnterAgain(true);
}}
id="save-and-new-bill-enter-modal"
>
{t("general.actions.saveandnew")}
</Button>
)}
</Space>
</Space>
}
destroyOnHidden

View File

@@ -4,6 +4,7 @@ import dayjs from "../../utils/day";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectDarkMode } from "../../redux/application/application.selectors.js";
import { useTranslation } from "react-i18next";
const mapStateToProps = createStructuredSelector({
isDarkMode: selectDarkMode
@@ -19,25 +20,35 @@ export function DmsLogEvents({
detailsNonce,
isDarkMode,
colorizeJson = false,
showDetails = true
showDetails = true,
allowXmlPayload = true
}) {
const { t } = useTranslation();
const [openSet, setOpenSet] = useState(() => new Set());
const [copiedKey, setCopiedKey] = useState(null);
// Inject JSON highlight styles once (only when colorize is enabled)
useEffect(() => {
if (!colorizeJson) return;
if (typeof document === "undefined") return;
if (document.getElementById("json-highlight-styles")) return;
const style = document.createElement("style");
style.id = "json-highlight-styles";
let style = document.getElementById("json-highlight-styles");
if (!style) {
style = document.createElement("style");
style.id = "json-highlight-styles";
document.head.appendChild(style);
}
style.textContent = `
.json-key { color: #fa8c16; }
.json-string { color: #52c41a; }
.json-number { color: #722ed1; }
.json-boolean { color: #1890ff; }
.json-null { color: #faad14; }
.xml-tag { color: #1677ff; }
.xml-attr { color: #d46b08; }
.xml-value { color: #389e0d; }
.xml-decl { color: #7c3aed; }
.xml-comment { color: #8c8c8c; }
`;
document.head.appendChild(style);
}, [colorizeJson]);
// Trim openSet if logs shrink
@@ -65,6 +76,13 @@ export function DmsLogEvents({
// Only treat meta as "present" when we are allowed to show details
const hasMeta = !isEmpty(meta) && showDetails;
const isOpen = hasMeta && openSet.has(idx);
const xml = hasMeta && allowXmlPayload ? extractXmlFromMeta(meta) : { request: null, response: null };
const hasRequestXml = !!xml.request;
const hasResponseXml = !!xml.response;
const copyPayload = hasMeta ? getCopyPayload(meta) : null;
const copyPayloadKey = `copy-${idx}`;
const copyReqKey = `copy-req-${idx}`;
const copyResKey = `copy-res-${idx}`;
return {
key: idx,
@@ -92,10 +110,42 @@ export function DmsLogEvents({
return next;
})
}
style={{ cursor: "pointer", userSelect: "none" }}
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
>
{isOpen ? "Hide details" : "Details"}
{isOpen ? t("dms.labels.hide_details") : t("dms.labels.details")}
</a>
<Divider orientation="vertical" />
<a
role="button"
onClick={() => handleCopyAction(copyPayloadKey, copyPayload, setCopiedKey)}
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
>
{copiedKey === copyPayloadKey ? t("dms.labels.copied") : t("dms.labels.copy")}
</a>
{hasRequestXml && (
<>
<Divider orientation="vertical" />
<a
role="button"
onClick={() => handleCopyAction(copyReqKey, xml.request, setCopiedKey)}
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
>
{copiedKey === copyReqKey ? t("dms.labels.copied") : t("dms.labels.copy_request")}
</a>
</>
)}
{hasResponseXml && (
<>
<Divider orientation="vertical" />
<a
role="button"
onClick={() => handleCopyAction(copyResKey, xml.response, setCopiedKey)}
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
>
{copiedKey === copyResKey ? t("dms.labels.copied") : t("dms.labels.copy_response")}
</a>
</>
)}
</>
)}
</Space>
@@ -103,14 +153,30 @@ export function DmsLogEvents({
{/* Row 2: details body (only when open) */}
{hasMeta && isOpen && (
<div style={{ marginLeft: 6 }}>
<JsonBlock isDarkMode={isDarkMode} data={meta} colorize={colorizeJson} />
<JsonBlock isDarkMode={isDarkMode} data={removeXmlFromMeta(meta)} colorize={colorizeJson} />
{hasRequestXml && (
<XmlBlock
isDarkMode={isDarkMode}
title={t("dms.labels.request_xml")}
xmlText={xml.request}
colorize={colorizeJson}
/>
)}
{hasResponseXml && (
<XmlBlock
isDarkMode={isDarkMode}
title={t("dms.labels.response_xml")}
xmlText={xml.response}
colorize={colorizeJson}
/>
)}
</div>
)}
</Space>
)
};
}),
[logs, openSet, colorizeJson, isDarkMode, showDetails]
[logs, openSet, colorizeJson, copiedKey, isDarkMode, showDetails, allowXmlPayload, t]
);
return <Timeline reverse items={items} />;
@@ -179,6 +245,121 @@ const safeStringify = (obj, spaces = 2) => {
}
};
/**
* Get request/response XML from various Reynolds log meta shapes.
* @param meta
* @returns {{request: string|null, response: string|null}}
*/
const extractXmlFromMeta = (meta) => {
const request =
firstString(meta?.requestXml) ||
firstString(meta?.xml?.request) ||
firstString(meta?.response?.xml?.request) ||
firstString(meta?.response?.requestXml);
const response =
firstString(meta?.responseXml) || firstString(meta?.xml?.response) || firstString(meta?.response?.xml?.response);
return { request, response };
};
/**
* Return the value to copy when clicking the "Copy" action.
* @param meta
* @returns {*}
*/
const getCopyPayload = (meta) => {
if (meta?.payload != null) return meta.payload;
return meta;
};
/**
* Remove bulky XML fields from object shown in JSON block (XML is rendered separately).
* @param meta
* @returns {*}
*/
const removeXmlFromMeta = (meta) => {
if (meta == null || typeof meta !== "object") return meta;
const cloned = safeClone(meta);
if (cloned == null || typeof cloned !== "object") return meta;
if (typeof cloned.requestXml === "string") delete cloned.requestXml;
if (typeof cloned.responseXml === "string") delete cloned.responseXml;
if (cloned.xml && typeof cloned.xml === "object") {
if (typeof cloned.xml.request === "string") delete cloned.xml.request;
if (typeof cloned.xml.response === "string") delete cloned.xml.response;
if (isEmpty(cloned.xml)) delete cloned.xml;
}
if (cloned.response?.xml && typeof cloned.response.xml === "object") {
if (typeof cloned.response.xml.request === "string") delete cloned.response.xml.request;
if (typeof cloned.response.xml.response === "string") delete cloned.response.xml.response;
if (isEmpty(cloned.response.xml)) delete cloned.response.xml;
}
return cloned;
};
/**
* Safe deep clone for plain JSON structures.
* @param value
* @returns {*}
*/
const safeClone = (value) => {
try {
return JSON.parse(JSON.stringify(value));
} catch {
return value;
}
};
/**
* First non-empty string helper.
* @param value
* @returns {string|null}
*/
const firstString = (value) => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
};
/**
* Copy arbitrary text/object to clipboard.
* @param key
* @param value
* @param setCopied
* @returns {Promise<void>}
*/
const handleCopyAction = async (key, value, setCopied) => {
const text = typeof value === "string" ? value : safeStringify(value, 2);
if (!text) return;
const copied = await copyTextToClipboard(text);
if (!copied) return;
setCopied(key);
setTimeout(() => {
setCopied((prev) => (prev === key ? null : prev));
}, 1200);
};
/**
* Clipboard helper (modern async Clipboard API).
* @param text
* @returns {Promise<boolean>}
*/
const copyTextToClipboard = async (text) => {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
return false;
}
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
};
/**
* JSON display block with optional syntax highlighting.
* @param data
@@ -210,6 +391,105 @@ const JsonBlock = ({ data, colorize, isDarkMode }) => {
return <pre style={preStyle}>{jsonText}</pre>;
};
/**
* XML display block with normalized indentation.
* @param title
* @param xmlText
* @param isDarkMode
* @returns {JSX.Element}
* @constructor
*/
const XmlBlock = ({ title, xmlText, isDarkMode, colorize = false }) => {
const base = {
margin: "8px 0 0",
maxWidth: 720,
overflowX: "auto",
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 12,
lineHeight: 1.45,
padding: 8,
borderRadius: 6,
background: isDarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.04)",
border: isDarkMode ? "1px solid rgba(255,255,255,0.12)" : "1px solid rgba(0,0,0,0.08)",
color: isDarkMode ? "var(--card-text-fallback)" : "#141414",
whiteSpace: "pre"
};
return (
<div style={{ marginTop: 6 }}>
<div style={{ fontSize: 11, fontWeight: 600 }}>{title}</div>
{colorize ? (
<pre style={base} dangerouslySetInnerHTML={{ __html: highlightXml(formatXml(xmlText)) }} />
) : (
<pre style={base}>{formatXml(xmlText)}</pre>
)}
</div>
);
};
/**
* Basic XML pretty-printer.
* @param xml
* @returns {string}
*/
const formatXml = (xml) => {
if (typeof xml !== "string") return "";
const normalized = xml.replace(/\r\n/g, "\n").replace(/>\s*</g, ">\n<").trim();
const lines = normalized.split("\n");
let indent = 0;
const out = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) continue;
if (/^<\/[^>]+>/.test(line)) indent = Math.max(indent - 1, 0);
out.push(`${" ".repeat(indent)}${line}`);
const opens = (line.match(/<[^/!?][^>]*>/g) || []).length;
const closes = (line.match(/<\/[^>]+>/g) || []).length;
const selfClosing = (line.match(/<[^>]+\/>/g) || []).length;
const declaration = /^<\?xml/.test(line) ? 1 : 0;
indent += opens - closes - selfClosing - declaration;
if (indent < 0) indent = 0;
}
return out.join("\n");
};
/**
* Syntax highlight pretty-printed XML text for HTML display.
* @param xmlText
* @returns {string}
*/
const highlightXml = (xmlText) => {
const esc = String(xmlText || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const lines = esc.split("\n");
return lines
.map((line) => {
let out = line;
out = out.replace(/(&lt;!--[\s\S]*?--&gt;)/g, '<span class="xml-comment">$1</span>');
out = out.replace(/(&lt;\?xml[\s\S]*?\?&gt;)/g, '<span class="xml-decl">$1</span>');
out = out.replace(/(&lt;\/?)([A-Za-z_][\w:.-]*)([\s\S]*?)(\/?&gt;)/g, (_m, open, tag, attrs, close) => {
const coloredAttrs = attrs.replace(
/([A-Za-z_][\w:.-]*)(=)("[^"]*"|'[^']*'|&quot;[\s\S]*?&quot;|&apos;[\s\S]*?&apos;)/g,
'<span class="xml-attr">$1</span>$2<span class="xml-value">$3</span>'
);
return `${open}<span class="xml-tag">${tag}</span>${coloredAttrs}${close}`;
});
return out;
})
.join("\n");
};
/**
* Syntax highlight JSON text for HTML display.
* @param jsonText

View File

@@ -41,7 +41,7 @@ export function EmailOverlayComponent({ emailConfig, form, selectedMediaState, b
const emailsToMenu = {
items: [
...bodyshop.employees
.filter((e) => e.user_email)
.filter((e) => e.user_email && e.active === true)
.map((e, idx) => ({
key: idx,
label: `${e.first_name} ${e.last_name}`,
@@ -59,7 +59,7 @@ export function EmailOverlayComponent({ emailConfig, form, selectedMediaState, b
const menuCC = {
items: [
...bodyshop.employees
.filter((e) => e.user_email)
.filter((e) => e.user_email && e.active === true)
.map((e, idx) => ({
key: idx,
label: `${e.first_name} ${e.last_name}`,

View File

@@ -25,6 +25,7 @@ const Eula = ({ currentEula, currentUser, acceptEula }) => {
const handleScroll = useCallback(
(e) => {
if (!e.target) return;
const bottom = e.target.scrollHeight - 100 <= e.target.scrollTop + e.target.clientHeight;
if (bottom && !hasEverScrolledToBottom) {
setHasEverScrolledToBottom(true);
@@ -36,7 +37,9 @@ const Eula = ({ currentEula, currentUser, acceptEula }) => {
);
useEffect(() => {
handleScroll({ target: markdownCardRef.current });
if (markdownCardRef.current) {
handleScroll({ target: markdownCardRef.current });
}
}, [handleScroll]);
const handleChange = useCallback(() => {

View File

@@ -33,7 +33,7 @@ import JobLinesBillRefernece from "../job-lines-bill-reference/job-lines-bill-re
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import _ from "lodash";
import { FaTasks } from "react-icons/fa";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { selectAuthLevel, selectBodyshop } from "../../redux/user/user.selectors";
import dayjs from "../../utils/day";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
@@ -49,6 +49,7 @@ import JobLinesPartPriceChange from "./job-lines-part-price-change.component";
import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component.jsx";
const UPDATE_JOB_LINES_LOCATION_BULK = gql`
mutation UPDATE_JOB_LINES_LOCATION_BULK($ids: [uuid!]!, $location: String!) {
@@ -66,7 +67,8 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
jobRO: selectJobReadOnly,
technician: selectTechnician,
isPartsEntry: selectIsPartsEntry
isPartsEntry: selectIsPartsEntry,
authLevel: selectAuthLevel
});
const mapDispatchToProps = (dispatch) => ({
@@ -94,7 +96,8 @@ export function JobLinesComponent({
setTaskUpsertContext,
billsQuery,
handlePartsOrderOnRowClick,
isPartsEntry
isPartsEntry,
authLevel
}) {
const [deleteJobLine] = useMutation(DELETE_JOB_LINE_BY_PK);
const [bulkUpdateLocations] = useMutation(UPDATE_JOB_LINES_LOCATION_BULK);
@@ -386,18 +389,20 @@ export function JobLinesComponent({
key: "actions",
render: (text, record) => (
<Space>
{(record.manual_line || jobIsPrivate) && !technician && (
<Button
disabled={jobRO}
onClick={() => {
setJobLineEditContext({
actions: { refetch: refetch, submit: form && form.submit },
context: { ...record, jobid: job.id }
});
}}
icon={<EditFilled />}
/>
)}
{(record.manual_line || jobIsPrivate) &&
!technician &&
HasRbacAccess({ bodyshop, authLevel, action: "jobs:manual-line" }) && (
<Button
disabled={jobRO}
onClick={() => {
setJobLineEditContext({
actions: { refetch: refetch, submit: form && form.submit },
context: { ...record, jobid: job.id }
});
}}
icon={<EditFilled />}
/>
)}
<Button
title={t("tasks.buttons.create")}
onClick={() => {
@@ -410,29 +415,30 @@ export function JobLinesComponent({
}}
icon={<FaTasks />}
/>
{(record.manual_line || jobIsPrivate) && !technician && (
<Button
disabled={jobRO}
onClick={async () => {
await deleteJobLine({
variables: { joblineId: record.id },
update(cache) {
cache.modify({
fields: {
joblines(existingJobLines, { readField }) {
return existingJobLines.filter((jlRef) => record.id !== readField("id", jlRef));
{(record.manual_line || jobIsPrivate) &&
!technician &&
HasRbacAccess({ bodyshop, authLevel, action: "jobs:manual-line" }) && (
<Button
disabled={jobRO}
onClick={async () => {
await deleteJobLine({
variables: { joblineId: record.id },
update(cache) {
cache.modify({
fields: {
joblines(existingJobLines, { readField }) {
return existingJobLines.filter((jlRef) => record.id !== readField("id", jlRef));
}
}
}
});
}
});
await axios.post("/job/totalsssu", { id: job.id });
if (refetch) refetch();
}}
icon={<DeleteFilled />}
/>
)}
});
}
});
await axios.post("/job/totalsssu", { id: job.id });
if (refetch) refetch();
}}
icon={<DeleteFilled />}
/>
)}
</Space>
)
}
@@ -657,7 +663,7 @@ export function JobLinesComponent({
<Button id="repair-data-mark-button">{t("jobs.actions.mark")}</Button>
</Dropdown>
{!isPartsEntry && (
{!isPartsEntry && HasRbacAccess({ bodyshop, authLevel, action: "jobs:manual-line" }) && (
<Button
disabled={jobRO || technician}
onClick={() => {

View File

@@ -144,18 +144,11 @@ export default function JobTotalsTableLabor({ job }) {
{t("jobs.labels.mapa")}
{InstanceRenderManager({
imex:
job.materials?.mapa &&
job.materials.mapa.cal_maxdlr &&
job.materials.mapa.cal_maxdlr > 0 &&
t("jobs.labels.threshhold", {
amount: job.materials.mapa.cal_maxdlr
}),
(job.materials?.mapa ?? job.materials?.MAPA)?.cal_maxdlr > 0 &&
t("jobs.labels.threshhold", { amount: (job.materials.mapa ?? job.materials.MAPA).cal_maxdlr }),
rome:
job.materials?.MAPA &&
job.materials.MAPA.cal_maxdlr !== undefined &&
t("jobs.labels.threshhold", {
amount: job.materials.MAPA.cal_maxdlr
})
job.materials?.MAPA?.cal_maxdlr !== undefined &&
t("jobs.labels.threshhold", { amount: job.materials.MAPA.cal_maxdlr })
})}
</Space>
</ResponsiveTable.Summary.Cell>
@@ -190,18 +183,11 @@ export default function JobTotalsTableLabor({ job }) {
{t("jobs.labels.mash")}
{InstanceRenderManager({
imex:
job.materials?.mash &&
job.materials.mash.cal_maxdlr &&
job.materials.mash.cal_maxdlr > 0 &&
t("jobs.labels.threshhold", {
amount: job.materials.mash.cal_maxdlr
}),
(job.materials?.mash ?? job.materials?.MASH)?.cal_maxdlr > 0 &&
t("jobs.labels.threshhold", { amount: (job.materials.mash ?? job.materials.MASH).cal_maxdlr }),
rome:
job.materials?.MASH &&
job.materials.MASH.cal_maxdlr !== undefined &&
t("jobs.labels.threshhold", {
amount: job.materials.MASH.cal_maxdlr
})
job.materials?.MASH?.cal_maxdlr !== undefined &&
t("jobs.labels.threshhold", { amount: job.materials.MASH.cal_maxdlr })
})}
</Space>
</ResponsiveTable.Summary.Cell>

View File

@@ -69,7 +69,9 @@ export function JobsAdminClass({ bodyshop, job }) {
</Form>
<Popconfirm title={t("jobs.labels.changeclass")} onConfirm={() => form.submit()}>
<Button loading={loading}>{t("general.actions.save")}</Button>
<Button loading={loading} type="primary">
{t("general.actions.save")}
</Button>
</Popconfirm>
</div>
);

View File

@@ -157,7 +157,7 @@ export function JobsAdminDatesChange({ insertAuditTrail, job }) {
</LayoutFormRow>
</Form>
<Button loading={loading} onClick={() => form.submit()}>
<Button loading={loading} type="primary" onClick={() => form.submit()}>
{t("general.actions.save")}
</Button>
</div>

View File

@@ -54,7 +54,7 @@ export default function JobAdminOwnerReassociate({ job }) {
</Form.Item>
</Form>
<div>{t("jobs.labels.associationwarning")}</div>
<Button loading={loading} onClick={() => form.submit()}>
<Button loading={loading} type="primary" onClick={() => form.submit()}>
{t("general.actions.save")}
</Button>
</div>

View File

@@ -54,7 +54,7 @@ export default function JobAdminOwnerReassociate({ job }) {
</Form.Item>
</Form>
<div>{t("jobs.labels.associationwarning")}</div>
<Button loading={loading} onClick={() => form.submit()}>
<Button loading={loading} type="primary" onClick={() => form.submit()}>
{t("general.actions.save")}
</Button>
</div>

View File

@@ -138,7 +138,7 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
option?.value?.toLowerCase().indexOf(input?.toLowerCase()) >= 0
}}
disabled={jobRO}
options={bodyshop.md_responsibility_centers.profits.map((p) => ({
@@ -166,7 +166,7 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
option?.value?.toLowerCase().indexOf(input?.toLowerCase()) >= 0
}}
disabled={jobRO}
options={bodyshop.md_responsibility_centers.profits.map((p) => ({

View File

@@ -70,6 +70,12 @@ export function PartsOrderListTableComponent({
const [deletePartsOrder] = useMutation(DELETE_PARTS_ORDER);
const parts_orders = billsQuery.data ? billsQuery.data.parts_orders : [];
const enrichedPartsOrders = parts_orders.map((order) => ({
...order,
invoice_number: order.bill?.invoice_number
}));
const { refetch } = billsQuery;
const recordActions = (record, showView = false) => (
@@ -222,7 +228,12 @@ export function PartsOrderListTableComponent({
dataIndex: "order_number",
key: "order_number",
sorter: (a, b) => alphaSort(a.invoice_number, b.invoice_number),
sortOrder: state.sortedInfo.columnKey === "invoice_number" && state.sortedInfo.order
sortOrder: state.sortedInfo.columnKey === "invoice_number" && state.sortedInfo.order,
render: (text, record) => (
<span>
{record.order_number} {record.invoice_number && `(${record.invoice_number})`}
</span>
)
},
{
title: t("parts_orders.fields.order_date"),
@@ -272,10 +283,10 @@ export function PartsOrderListTableComponent({
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
};
const filteredPartsOrders = parts_orders
const filteredPartsOrders = enrichedPartsOrders
? searchText === ""
? parts_orders
: parts_orders.filter(
? enrichedPartsOrders
: enrichedPartsOrders.filter(
(b) =>
(b.order_number || "").toString().toLowerCase().includes(searchText.toLowerCase()) ||
(b.vendor.name || "").toLowerCase().includes(searchText.toLowerCase())

View File

@@ -1,7 +1,7 @@
import Icon from "@ant-design/icons";
import { useMutation } from "@apollo/client/react";
import { Button, Input, Popover, Tooltip } from "antd";
import { useState } from "react";
import { useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { FaRegStickyNote } from "react-icons/fa";
import { UPDATE_JOB } from "../../graphql/jobs.queries";
@@ -9,10 +9,10 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
export default function ProductionListColumnComment({ record, usePortal = false }) {
const { t } = useTranslation();
const [note, setNote] = useState(record.comment || "");
const [open, setOpen] = useState(false);
const textAreaRef = useRef(null);
const rafIdRef = useRef(null);
const [updateAlert] = useMutation(UPDATE_JOB);
@@ -38,23 +38,35 @@ export default function ProductionListColumnComment({ record, usePortal = false
};
const handleOpenChange = (flag) => {
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
setOpen(flag);
if (flag) setNote(record.comment || "");
if (flag) {
setNote(record.comment || "");
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null;
if (textAreaRef.current?.focus) {
try {
textAreaRef.current.focus({ preventScroll: true });
} catch {
textAreaRef.current.focus();
}
}
});
}
};
const content = (
<div
style={{ width: "30em" }}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<div style={{ width: "30em" }} onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>
<Input.TextArea
id={`job-comment-${record.id}`}
name="comment"
rows={5}
value={note}
onChange={handleChange}
autoFocus
ref={textAreaRef}
allowClear
style={{ marginBottom: "1em" }}
/>
@@ -67,13 +79,13 @@ export default function ProductionListColumnComment({ record, usePortal = false
);
return (
<Popover
onOpenChange={handleOpenChange}
open={open}
content={content}
trigger="click"
<Popover
onOpenChange={handleOpenChange}
open={open}
content={content}
trigger="click"
destroyOnHidden
styles={{ body: { padding: '12px' } }}
styles={{ body: { padding: "12px" } }}
{...(usePortal ? { getPopupContainer: (trigger) => trigger.parentElement || document.body } : {})}
>
<div

View File

@@ -1,7 +1,7 @@
import Icon from "@ant-design/icons";
import { useMutation } from "@apollo/client/react";
import { Button, Input, Popover, Space } from "antd";
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaRegStickyNote } from "react-icons/fa";
import { logImEXEvent } from "../../firebase/firebase.utils";
@@ -20,6 +20,8 @@ function ProductionListColumnProductionNote({ record, setNoteUpsertContext, useP
const { t } = useTranslation();
const [note, setNote] = useState(record.production_vars?.note || "");
const [open, setOpen] = useState(false);
const textAreaRef = useRef(null);
const rafIdRef = useRef(null);
const [updateAlert] = useMutation(UPDATE_JOB);
@@ -52,25 +54,37 @@ function ProductionListColumnProductionNote({ record, setNoteUpsertContext, useP
const handleOpenChange = useCallback(
(flag) => {
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
setOpen(flag);
if (flag) setNote(record.production_vars?.note || "");
if (flag) {
setNote(record.production_vars?.note || "");
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null;
if (textAreaRef.current?.focus) {
try {
textAreaRef.current.focus({ preventScroll: true });
} catch {
textAreaRef.current.focus();
}
}
});
}
},
[record]
);
const content = (
<div
style={{ width: "30em" }}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<div style={{ width: "30em" }} onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>
<Input.TextArea
id={`job-production-note-${record.id}`}
name="production_note"
rows={5}
value={note}
onChange={handleChange}
autoFocus
ref={textAreaRef}
allowClear
style={{ marginBottom: "1em" }}
/>
@@ -96,13 +110,13 @@ function ProductionListColumnProductionNote({ record, setNoteUpsertContext, useP
);
return (
<Popover
onOpenChange={handleOpenChange}
open={open}
content={content}
trigger="click"
<Popover
onOpenChange={handleOpenChange}
open={open}
content={content}
trigger="click"
destroyOnHidden
styles={{ body: { padding: '12px' } }}
styles={{ body: { padding: "12px" } }}
{...(usePortal ? { getPopupContainer: (trigger) => trigger.parentElement || document.body } : {})}
>
<div

View File

@@ -26,6 +26,7 @@ const ret = {
"jobs:partsqueue": 4,
"jobs:checklist-view": 2,
"jobs:list-ready": 1,
"jobs:manual-line": 1,
"jobs:void": 5,
"bills:enter": 2,

View File

@@ -0,0 +1,99 @@
import { Grid, Table } from "antd";
import { useMemo } from "react";
import "./responsive-table.styles.scss";
function ResponsiveTable({ className, columns, mobileColumnKeys, scroll, tableLayout, ...rest }) {
const screens = Grid.useBreakpoint();
const isPhone = !screens.md;
const isCompactViewport = !screens.lg;
const prefersHorizontalScroll = isPhone || isCompactViewport;
const isResponsiveFilteringEnabled = ["1", "true", "yes", "on"].includes(
String(import.meta.env.VITE_APP_ENABLE_RESPONSIVE_TABLE_FILTERING || "")
.trim()
.toLowerCase()
);
const resolvedColumns = useMemo(() => {
if (
!isResponsiveFilteringEnabled ||
!Array.isArray(columns) ||
!isPhone ||
!Array.isArray(mobileColumnKeys) ||
mobileColumnKeys.length === 0
) {
return columns;
}
const visibleColumnKeys = new Set(mobileColumnKeys);
const filteredColumns = columns.filter((column) => {
const key = column?.key ?? column?.dataIndex;
// Keep columns with no stable key to avoid accidental loss.
if (key == null) return true;
if (Array.isArray(key)) {
return key.some((part) => visibleColumnKeys.has(part));
}
return visibleColumnKeys.has(key);
});
return filteredColumns.length > 0 ? filteredColumns : columns;
}, [columns, isPhone, isResponsiveFilteringEnabled, mobileColumnKeys]);
const resolvedScroll = useMemo(() => {
if (prefersHorizontalScroll) {
if (scroll == null) {
return { x: "max-content" };
}
if (typeof scroll !== "object" || Array.isArray(scroll)) {
return scroll;
}
const { x, ...baseScroll } = scroll;
return { ...baseScroll, x: x ?? "max-content" };
}
if (scroll == null) {
// Explicitly override ConfigProvider table.scroll desktop defaults.
return {};
}
if (typeof scroll !== "object" || Array.isArray(scroll)) {
return scroll;
}
const { x, ...desktopScroll } = scroll;
// On desktop we prefer fitting columns with ellipsis over forced horizontal scroll.
if (x == null) {
return desktopScroll;
}
return desktopScroll;
}, [prefersHorizontalScroll, scroll]);
const resolvedTableLayout = tableLayout ?? (prefersHorizontalScroll ? "auto" : "fixed");
const responsiveClassName = prefersHorizontalScroll ? undefined : "responsive-table-fit";
const resolvedClassName = [responsiveClassName, className].filter(Boolean).join(" ");
return (
<Table
className={resolvedClassName}
columns={resolvedColumns}
scroll={resolvedScroll}
tableLayout={resolvedTableLayout}
{...rest}
/>
);
}
ResponsiveTable.Summary = Table.Summary;
ResponsiveTable.Column = Table.Column;
ResponsiveTable.ColumnGroup = Table.ColumnGroup;
ResponsiveTable.SELECTION_COLUMN = Table.SELECTION_COLUMN;
ResponsiveTable.EXPAND_COLUMN = Table.EXPAND_COLUMN;
export default ResponsiveTable;

View File

@@ -1,93 +1,7 @@
import { Grid, Table } from "antd";
import { useMemo } from "react";
import "./responsive-table.styles.scss";
import { Table } from "antd";
function ResponsiveTable({ className, columns, mobileColumnKeys, scroll, tableLayout, ...rest }) {
const screens = Grid.useBreakpoint();
const isPhone = !screens.md;
const isCompactViewport = !screens.lg;
const prefersHorizontalScroll = isPhone || isCompactViewport;
const isResponsiveFilteringEnabled = ["1", "true", "yes", "on"].includes(
String(import.meta.env.VITE_APP_ENABLE_RESPONSIVE_TABLE_FILTERING || "")
.trim()
.toLowerCase()
);
const resolvedColumns = useMemo(() => {
if (
!isResponsiveFilteringEnabled ||
!Array.isArray(columns) ||
!isPhone ||
!Array.isArray(mobileColumnKeys) ||
mobileColumnKeys.length === 0
) {
return columns;
}
const visibleColumnKeys = new Set(mobileColumnKeys);
const filteredColumns = columns.filter((column) => {
const key = column?.key ?? column?.dataIndex;
// Keep columns with no stable key to avoid accidental loss.
if (key == null) return true;
if (Array.isArray(key)) {
return key.some((part) => visibleColumnKeys.has(part));
}
return visibleColumnKeys.has(key);
});
return filteredColumns.length > 0 ? filteredColumns : columns;
}, [columns, isPhone, isResponsiveFilteringEnabled, mobileColumnKeys]);
const resolvedScroll = useMemo(() => {
if (prefersHorizontalScroll) {
if (scroll == null) {
return { x: "max-content" };
}
if (typeof scroll !== "object" || Array.isArray(scroll)) {
return scroll;
}
const { x, ...baseScroll } = scroll;
return { ...baseScroll, x: x ?? "max-content" };
}
if (scroll == null) {
// Explicitly override ConfigProvider table.scroll desktop defaults.
return {};
}
if (typeof scroll !== "object" || Array.isArray(scroll)) {
return scroll;
}
const { x, ...desktopScroll } = scroll;
// On desktop we prefer fitting columns with ellipsis over forced horizontal scroll.
if (x == null) {
return desktopScroll;
}
return desktopScroll;
}, [prefersHorizontalScroll, scroll]);
const resolvedTableLayout = tableLayout ?? (prefersHorizontalScroll ? "auto" : "fixed");
const responsiveClassName = prefersHorizontalScroll ? undefined : "responsive-table-fit";
const resolvedClassName = [responsiveClassName, className].filter(Boolean).join(" ");
return (
<Table
className={resolvedClassName}
columns={resolvedColumns}
scroll={resolvedScroll}
tableLayout={resolvedTableLayout}
{...rest}
/>
);
function ResponsiveTable(props) {
return <Table {...props} />;
}
ResponsiveTable.Summary = Table.Summary;

View File

@@ -435,6 +435,19 @@ export function ShopInfoRbacComponent({ bodyshop }) {
>
<InputNumber />
</Form.Item>,
<Form.Item
key="jobs:manual-line"
label={t("bodyshop.fields.rbac.jobs.manual-line")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_rbac", "jobs:manual-line"]}
>
<InputNumber />
</Form.Item>,
<Form.Item
key="jobs:partsqueue"
label={t("bodyshop.fields.rbac.jobs.partsqueue")}

View File

@@ -66,10 +66,9 @@ export function TechClockInContainer({ setTimeTicketContext, technician, bodysho
employeeid: technician.id,
date:
typeof bodyshop.timezone === "string"
? // TODO: Client Update - This may be broken
dayjs.tz(theTime, bodyshop.timezone).format("YYYY-MM-DD")
? dayjs(theTime).tz(bodyshop.timezone).format("YYYY-MM-DD")
: typeof bodyshop.timezone === "number"
? dayjs(theTime).format("YYYY-MM-DD").utcOffset(bodyshop.timezone)
? dayjs(theTime).utcOffset(bodyshop.timezone).format("YYYY-MM-DD")
: dayjs(theTime).format("YYYY-MM-DD"),
clockon: dayjs(theTime),
jobid: values.jobid,

View File

@@ -25,10 +25,7 @@ const mapDispatchToProps = (dispatch) => ({
});
export function TechLookupJobsDrawer({ bodyshop, setPrintCenterContext }) {
const breakpoints = Grid.useBreakpoint();
const selectedBreakpoint = Object.entries(breakpoints)
.filter(([, isOn]) => !!isOn)
.slice(-1)[0];
const screens = Grid.useBreakpoint();
const bpoints = {
xs: "100%",
@@ -36,10 +33,16 @@ export function TechLookupJobsDrawer({ bodyshop, setPrintCenterContext }) {
md: "100%",
lg: "100%",
xl: "90%",
xxl: "85%"
xxl: "90%"
};
const drawerPercentage = selectedBreakpoint ? bpoints[selectedBreakpoint[0]] : "100%";
let drawerPercentage = "100%";
if (screens.xxl) drawerPercentage = bpoints.xxl;
else if (screens.xl) drawerPercentage = bpoints.xl;
else if (screens.lg) drawerPercentage = bpoints.lg;
else if (screens.md) drawerPercentage = bpoints.md;
else if (screens.sm) drawerPercentage = bpoints.sm;
else if (screens.xs) drawerPercentage = bpoints.xs;
const location = useLocation();
const history = useNavigate();

View File

@@ -47,7 +47,7 @@ export function TimeTicketModalComponent({
} = useTreatmentsWithConfig({
attributes: {},
names: ["Enhanced_Payroll"],
splitKey: bodyshop.imexshopid
splitKey: bodyshop?.imexshopid
});
const [loadLineTicketData, { loading, data: lineTicketData, refetch }] = useLazyQuery(GET_LINE_TICKET_BY_PK, {
@@ -347,7 +347,7 @@ export function LaborAllocationContainer({
} = useTreatmentsWithConfig({
attributes: {},
names: ["Enhanced_Payroll"],
splitKey: bodyshop.imexshopid
splitKey: bodyshop?.imexshopid
});
if (loading) return <LoadingSkeleton />;

View File

@@ -91,6 +91,10 @@ export const QUERY_PARTS_BILLS_BY_JOBID = gql`
order_number
comments
user_email
bill {
id
invoice_number
}
}
parts_dispatch(where: { jobid: { _eq: $jobid } }) {
id

View File

@@ -1375,6 +1375,9 @@ export const QUERY_JOB_FOR_DUPE = gql`
agt_ph2x
area_of_damage
cat_no
cieca_pfl
cieca_pfo
cieca_pft
cieca_stl
cieca_ttl
clm_addr1
@@ -1452,6 +1455,7 @@ export const QUERY_JOB_FOR_DUPE = gql`
labor_rate_desc
labor_rate_id
local_tax_rate
materials
other_amount_payable
owner_owing
ownerid

View File

@@ -11,7 +11,7 @@ import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { QUERY_JOB_EXPORT_DMS } from "../../graphql/jobs.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
@@ -29,7 +29,8 @@ import DmsAllocationsSummary from "../../components/dms-allocations-summary/dms-
import RrAllocationsSummary from "../../components/dms-allocations-summary/rr-dms-allocations-summary.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
});
const mapDispatchToProps = (dispatch) => ({
@@ -65,7 +66,41 @@ const DMS_SOCKET_EVENTS = {
}
};
export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, insertAuditTrail }) {
const stripRrXmlFromPayload = (input) => {
if (input == null || typeof input !== "object") return input;
let target = null;
try {
target = JSON.parse(JSON.stringify(input));
} catch {
// Fallback to in-place scrub if cloning fails.
target = input;
}
const scrub = (node) => {
if (node == null || typeof node !== "object") return;
if (Array.isArray(node)) {
node.forEach(scrub);
return;
}
delete node.requestXml;
delete node.responseXml;
if (node.xml && typeof node.xml === "object") {
delete node.xml.request;
delete node.xml.response;
if (Object.keys(node.xml).length === 0) delete node.xml;
}
Object.values(node).forEach(scrub);
};
scrub(target);
return target;
};
export function DmsContainer({ bodyshop, currentUser, setBreadcrumbs, setSelectedHeader, insertAuditTrail }) {
const {
treatments: { Fortellis }
} = useTreatmentsWithConfig({
@@ -79,6 +114,15 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
const [allocationsSummary, setAllocationsSummary] = useState(null);
const [reconnectNonce, setReconnectNonce] = useState(0);
const isDevEnv = import.meta.env.DEV;
const isProdEnv = import.meta.env.PROD;
const userEmail = (currentUser?.email || "").toLowerCase();
const devEmails = ["imex.dev", "rome.dev"];
const prodEmails = ["imex.prod", "rome.prod", "imex.test", "rome.test"];
const hasValidEmail = (emails) => emails.some((email) => userEmail.endsWith(email));
const canViewSensitiveRrXml = (isDevEnv && hasValidEmail(devEmails)) || (isProdEnv && hasValidEmail(prodEmails));
// Compute a single normalized mode and pick the proper socket
const mode = getDmsMode(bodyshop, Fortellis.treatment); // "rr" | "fortellis" | "cdk" | "pbs" | "none"
@@ -164,19 +208,21 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
const providerLabel = useMemo(
() =>
({
[DMS_MAP.reynolds]: "Reynolds",
[DMS_MAP.fortellis]: "Fortellis",
[DMS_MAP.cdk]: "CDK",
[DMS_MAP.pbs]: "PBS"
})[mode] || "DMS",
[mode]
[DMS_MAP.reynolds]: t("dms.labels.provider_reynolds"),
[DMS_MAP.fortellis]: t("dms.labels.provider_fortellis"),
[DMS_MAP.cdk]: t("dms.labels.provider_cdk"),
[DMS_MAP.pbs]: t("dms.labels.provider_pbs")
})[mode] || t("dms.labels.provider_dms"),
[mode, t]
);
const transportLabel = isWssMode(mode) ? "(WSS)" : "(WS)";
const transportLabel = isWssMode(mode) ? t("dms.labels.transport_wss") : t("dms.labels.transport_ws");
const bannerMessage = `Posting to ${providerLabel} | ${transportLabel} | ${
isConnected ? "Connected" : "Disconnected"
}`;
const bannerMessage = t("dms.labels.banner_message", {
provider: providerLabel,
transport: transportLabel,
status: isConnected ? t("dms.labels.banner_status_connected") : t("dms.labels.banner_status_disconnected")
});
const resetKey = useMemo(() => `${mode || "none"}-${jobId || "none"}`, [mode, jobId]);
const customerSelectorKey = useMemo(() => `${resetKey}-${reconnectNonce}`, [resetKey, reconnectNonce]);
@@ -239,6 +285,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
}, [jobId, mode, activeSocket]);
const handleExportFailed = (payload = {}) => {
const safePayload = canViewSensitiveRrXml ? payload : stripRrXmlFromPayload(payload);
const { title, friendlyMessage, error: errText, severity, errorCode, vendorStatusCode } = payload;
const msg =
@@ -246,7 +293,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
errText ||
t("dms.errors.exportfailedgeneric", "We couldn't complete the export. Please try again.");
const vendorTitle = title || (isRrMode ? "Reynolds" : "DMS");
const vendorTitle = title || (isRrMode ? t("dms.labels.provider_reynolds") : t("dms.labels.provider_dms"));
const isRrOpenRoLimit =
isRrMode &&
@@ -269,7 +316,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
timestamp: new Date(),
level: (sev || "error").toUpperCase(),
message: `${vendorTitle}: ${msg}`,
meta: { errorCode, vendorStatusCode, raw: payload, blockedByOpenRoLimit: !!isRrOpenRoLimit }
meta: { errorCode, vendorStatusCode, raw: safePayload, blockedByOpenRoLimit: !!isRrOpenRoLimit }
}
]);
};
@@ -321,7 +368,9 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
{
timestamp: new Date(),
level: "warn",
message: `Reconnected to ${isRrMode ? "RR" : mode === DMS_MAP.fortellis ? "Fortellis" : "DMS"} Export Service`
message: t("dms.labels.reconnected_export_service", {
provider: isRrMode ? t("dms.labels.provider_reynolds") : providerLabel
})
}
]);
};
@@ -340,11 +389,16 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
// Logs
const onLog = isRrMode
? (payload = {}) => {
const safePayload = canViewSensitiveRrXml ? payload : stripRrXmlFromPayload(payload);
const normalized = {
timestamp: payload.timestamp ? new Date(payload.timestamp) : payload.ts ? new Date(payload.ts) : new Date(),
level: (payload.level || "INFO").toUpperCase(),
message: payload.message || payload.msg || "",
meta: payload.meta ?? payload.ctx ?? payload.details ?? null
timestamp: safePayload.timestamp
? new Date(safePayload.timestamp)
: safePayload.ts
? new Date(safePayload.ts)
: new Date(),
level: (safePayload.level || "INFO").toUpperCase(),
message: safePayload.message || safePayload.msg || "",
meta: safePayload.meta ?? safePayload.ctx ?? safePayload.details ?? null
};
setLogs((prev) => [...prev, normalized]);
}
@@ -380,14 +434,12 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
{
timestamp: new Date(),
level: "INFO",
message:
"Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize."
message: t("dms.labels.rr_validation_message")
}
]);
notification.info({
title: "Reynolds RO created",
description:
"Complete validation in Reynolds, then click Finished/Close to finalize and mark this export complete.",
title: t("dms.labels.rr_validation_notice_title"),
description: t("dms.labels.rr_validation_notice_description"),
duration: 8
});
};
@@ -399,8 +451,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
{
timestamp: new Date(),
level: "INFO",
message:
"Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize.",
message: t("dms.labels.rr_validation_message"),
meta: { payload }
}
]);
@@ -428,7 +479,19 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
activeSocket.disconnect();
}
};
}, [mode, activeSocket, channels, logLevel, notification, t, insertAuditTrail, history]);
}, [
mode,
activeSocket,
channels,
logLevel,
notification,
t,
insertAuditTrail,
history,
isRrMode,
providerLabel,
canViewSensitiveRrXml
]);
// RR finalize callback (unchanged public behavior)
const handleRrValidationFinished = () => {
@@ -471,7 +534,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
<AlertComponent style={{ marginBottom: 10 }} title={bannerMessage} type="warning" showIcon closable />
<Row gutter={[16, 16]}>
<Col md={24} lg={10} className="dms-equal-height-col">
<Col xs={24} xxl={10} className="dms-equal-height-col dms-top-panel-col">
{!isRrMode ? (
<DmsAllocationsSummary
key={resetKey}
@@ -511,7 +574,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
)}
</Col>
<Col md={24} lg={14} className="dms-equal-height-col">
<Col xs={24} xxl={14} className="dms-equal-height-col dms-top-panel-col">
<DmsPostForm
key={resetKey}
socket={activeSocket}
@@ -550,15 +613,17 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
<Switch
checked={colorizeJson}
onChange={setColorizeJson}
checkedChildren="Color JSON"
unCheckedChildren="Plain JSON"
checkedChildren={t("dms.labels.color_json")}
unCheckedChildren={t("dms.labels.plain_json")}
/>
<Button onClick={toggleDetailsAll}>{detailsOpen ? "Collapse All" : "Expand All"}</Button>
<Button onClick={toggleDetailsAll}>
{detailsOpen ? t("dms.labels.collapse_all") : t("dms.labels.expand_all")}
</Button>
</>
)}
<Select
placeholder="Log Level"
placeholder={t("dms.labels.log_level")}
value={logLevel}
onChange={(value) => {
setLogLevel(value);
@@ -572,8 +637,8 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
{ key: "ERROR", value: "ERROR", label: "ERROR" }
]}
/>
<Button onClick={() => setLogs([])}>Clear Logs</Button>
<Button onClick={handleReconnectClick}>Reconnect</Button>
<Button onClick={() => setLogs([])}>{t("dms.labels.clear_logs")}</Button>
<Button onClick={handleReconnectClick}> {t("dms.labels.reconnect")}</Button>
</Space>
}
>
@@ -585,6 +650,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
detailsNonce={detailsNonce}
colorizeJson={isRrMode ? colorizeJson : false}
showDetails={isRrMode}
allowXmlPayload={canViewSensitiveRrXml}
/>
</Card>
</div>

View File

@@ -142,13 +142,13 @@ export function ExportLogsPageComponent() {
<div>
<ul>
{message.map((m, idx) => (
<li key={idx}>{m}</li>
<li key={idx}>{typeof m === "object" ? JSON.stringify(m) : m}</li>
))}
</ul>
</div>
);
} else {
return <div>{record.message}</div>;
return <div>{typeof message === "object" ? JSON.stringify(message) : message}</div>;
}
}
}

View File

@@ -10,14 +10,12 @@ import JobsCreateOwnerInfoContainer from "../../components/jobs-create-owner-inf
import JobsCreateVehicleInfoContainer from "../../components/jobs-create-vehicle-info/jobs-create-vehicle-info.container";
import JobCreateContext from "../../pages/jobs-create/jobs-create.context";
export default function JobsCreateComponent({ form }) {
export default function JobsCreateComponent({ form, isSubmitting }) {
const [pageIndex, setPageIndex] = useState(0);
const [errorMessage, setErrorMessage] = useState(null);
const [state] = useContext(JobCreateContext);
const { t } = useTranslation();
const steps = [
{
title: t("jobs.labels.create.vehicleinfo"),
@@ -42,11 +40,9 @@ export default function JobsCreateComponent({ form }) {
const next = () => {
setPageIndex(pageIndex + 1);
console.log("Next");
};
const prev = () => {
setPageIndex(pageIndex - 1);
console.log("Previous");
};
const ProgressButtons = ({ top }) => {
@@ -79,17 +75,19 @@ export default function JobsCreateComponent({ form }) {
{pageIndex === steps.length - 1 && (
<Button
type="primary"
loading={isSubmitting}
onClick={() => {
form
.validateFields()
.then(() => {
// NO OP
form.submit();
})
.catch((error) => console.log("error", error));
form.submit();
.catch((error) => {
console.log("error", error);
});
}}
>
Done
{t("general.actions.done")}
</Button>
)}
</Space>
@@ -146,13 +144,11 @@ export default function JobsCreateComponent({ form }) {
) : (
<div>
<ProgressButtons top />
{errorMessage ? (
<div>
<AlertComponent title={errorMessage} type="error" />
</div>
) : null}
{steps.map((item, idx) => (
<div
key={idx}

View File

@@ -46,6 +46,7 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr
});
const [form] = Form.useForm();
const [state, setState] = contextState;
const [isSubmitting, setIsSubmitting] = useState(false);
const [insertJob] = useMutation(INSERT_NEW_JOB);
const [loadOwner, remoteOwnerData] = useLazyQuery(QUERY_OWNER_FOR_JOB_CREATION);
@@ -83,16 +84,19 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr
newJobId: resp.data.insert_jobs.returning[0].id
});
logImEXEvent("manual_job_create_completed", {});
setIsSubmitting(false);
})
.catch((error) => {
notification.error({
title: t("jobs.errors.creating", { error: error })
});
setState({ ...state, error: error });
setIsSubmitting(false);
});
};
const handleFinish = (values) => {
setIsSubmitting(true);
let job = Object.assign(
{},
values,
@@ -297,7 +301,7 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr
})
}}
>
<JobsCreateComponent form={form} />
<JobsCreateComponent form={form} isSubmitting={isSubmitting} />
</Form>
</RbacWrapper>
</JobCreateContext.Provider>

View File

@@ -231,13 +231,16 @@
"overall": "Overall"
},
"disclaimer_title": "AI Scan Beta Disclaimer",
"feedback_placeholder": "Tell us what worked, what didn't, and what could be better.",
"feedback_prompt": "Was this AI scan helpful?",
"generic_failure": "Failed to process invoice.",
"multipage": "The is a multi-page document. Processing will take a few moments.",
"processing": "Analyzing Bill",
"scan": "AI Bill Scanner",
"scancomplete": "AI Scan Complete",
"scanfailed": "AI Scan Failed",
"scanstarted": "AI Scan Started"
"scanstarted": "AI Scan Started",
"submit_feedback": "Submit Feedback"
},
"bill_lines": "Bill Lines",
"bill_total": "Bill Total Amount",
@@ -519,6 +522,7 @@
"list-active": "Jobs -> List Active",
"list-all": "Jobs -> List All",
"list-ready": "Jobs -> List Ready",
"manual-line": "Jobs -> Manual Line",
"partsqueue": "Jobs -> Parts Queue",
"void": "Jobs -> Void"
},
@@ -1074,7 +1078,36 @@
"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."
"banner_message": "Posting to {{provider}} | {{transport}} | {{status}}",
"banner_status_connected": "Connected",
"banner_status_disconnected": "Disconnected",
"clear_logs": "Clear Logs",
"collapse_all": "Collapse All",
"color_json": "Color JSON",
"copied": "Copied",
"copy": "Copy",
"copy_request": "Copy Request",
"copy_response": "Copy Response",
"details": "Details",
"expand_all": "Expand All",
"hide_details": "Hide details",
"log_level": "Log Level",
"plain_json": "Plain JSON",
"provider_cdk": "CDK",
"provider_dms": "DMS",
"provider_fortellis": "Fortellis",
"provider_pbs": "PBS",
"provider_reynolds": "Reynolds",
"reconnect": "Reconnect",
"reconnected_export_service": "Reconnected to {{provider}} Export Service",
"refreshallocations": "Refresh to see DMS Allocations.",
"request_xml": "Request XML",
"response_xml": "Response XML",
"rr_validation_message": "Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize.",
"rr_validation_notice_description": "Complete validation in Reynolds, then click Finished/Close to finalize and mark this export complete.",
"rr_validation_notice_title": "Reynolds RO created",
"transport_ws": "(WS)",
"transport_wss": "(WSS)"
}
},
"documents": {
@@ -1266,6 +1299,7 @@
"delete": "Delete",
"deleteall": "Delete All",
"deselectall": "Deselect All",
"done": "Done",
"download": "Download",
"edit": "Edit",
"gotoadmin": "Go to Admin Panel",
@@ -3343,8 +3377,10 @@
"void_ros": "Void ROs",
"work_in_progress_committed_labour": "Work in Progress - Committed Labor",
"work_in_progress_jobs": "Work in Progress - Jobs",
"work_in_progress_labour": "Work in Progress - Labor",
"work_in_progress_payables": "Work in Progress - Payables"
"work_in_progress_labour": "Work in Progress - Labor (Detail)",
"work_in_progress_labour_summary": "Work in Progress - Labor (Summary)",
"work_in_progress_payables": "Work in Progress - Payables (Detail)",
"work_in_progress_payables_summary": "Work in Progress - Payables (Summary)"
}
},
"schedule": {

View File

@@ -231,13 +231,16 @@
"overall": ""
},
"disclaimer_title": "",
"feedback_placeholder": "",
"feedback_prompt": "",
"generic_failure": "",
"multipage": "",
"processing": "",
"scan": "",
"scancomplete": "",
"scanfailed": "",
"scanstarted": ""
"scanstarted": "",
"submit_feedback": ""
},
"bill_lines": "",
"bill_total": "",
@@ -519,6 +522,7 @@
"list-active": "",
"list-all": "",
"list-ready": "",
"manual-line": "",
"partsqueue": "",
"void": ""
},
@@ -1074,7 +1078,36 @@
"earlyrorequired.message": ""
},
"labels": {
"refreshallocations": ""
"banner_message": "",
"banner_status_connected": "",
"banner_status_disconnected": "",
"clear_logs": "",
"collapse_all": "",
"color_json": "",
"copied": "",
"copy": "",
"copy_request": "",
"copy_response": "",
"details": "",
"expand_all": "",
"hide_details": "",
"log_level": "",
"plain_json": "",
"provider_cdk": "",
"provider_dms": "",
"provider_fortellis": "",
"provider_pbs": "",
"provider_reynolds": "",
"reconnect": "",
"reconnected_export_service": "",
"refreshallocations": "",
"request_xml": "",
"response_xml": "",
"rr_validation_message": "",
"rr_validation_notice_description": "",
"rr_validation_notice_title": "",
"transport_ws": "",
"transport_wss": ""
}
},
"documents": {
@@ -1266,6 +1299,7 @@
"delete": "Borrar",
"deleteall": "",
"deselectall": "",
"done": "",
"download": "",
"edit": "Editar",
"gotoadmin": "",
@@ -3344,7 +3378,9 @@
"work_in_progress_committed_labour": "",
"work_in_progress_jobs": "",
"work_in_progress_labour": "",
"work_in_progress_payables": ""
"work_in_progress_labour_summary": "",
"work_in_progress_payables": "",
"work_in_progress_payables_summary": ""
}
},
"schedule": {

View File

@@ -231,13 +231,16 @@
"overall": ""
},
"disclaimer_title": "",
"feedback_placeholder": "",
"feedback_prompt": "",
"generic_failure": "",
"multipage": "",
"processing": "",
"scan": "",
"scancomplete": "",
"scanfailed": "",
"scanstarted": ""
"scanstarted": "",
"submit_feedback": ""
},
"bill_lines": "",
"bill_total": "",
@@ -519,6 +522,7 @@
"list-active": "",
"list-all": "",
"list-ready": "",
"manual-line": "",
"partsqueue": "",
"void": ""
},
@@ -1074,7 +1078,36 @@
"earlyrorequired.message": ""
},
"labels": {
"refreshallocations": ""
"banner_message": "",
"banner_status_connected": "",
"banner_status_disconnected": "",
"clear_logs": "",
"collapse_all": "",
"color_json": "",
"copied": "",
"copy": "",
"copy_request": "",
"copy_response": "",
"details": "",
"expand_all": "",
"hide_details": "",
"log_level": "",
"plain_json": "",
"provider_cdk": "",
"provider_dms": "",
"provider_fortellis": "",
"provider_pbs": "",
"provider_reynolds": "",
"reconnect": "",
"reconnected_export_service": "",
"refreshallocations": "",
"request_xml": "",
"response_xml": "",
"rr_validation_message": "",
"rr_validation_notice_description": "",
"rr_validation_notice_title": "",
"transport_ws": "",
"transport_wss": ""
}
},
"documents": {
@@ -1266,6 +1299,7 @@
"delete": "Effacer",
"deleteall": "",
"deselectall": "",
"done": "",
"download": "",
"edit": "modifier",
"gotoadmin": "",
@@ -3344,7 +3378,9 @@
"work_in_progress_committed_labour": "",
"work_in_progress_jobs": "",
"work_in_progress_labour": "",
"work_in_progress_payables": ""
"work_in_progress_labour_summary": "",
"work_in_progress_payables": "",
"work_in_progress_payables_summary": ""
}
},
"schedule": {

View File

@@ -1717,6 +1717,20 @@ export const TemplateList = (type, context) => {
group: "jobs",
featureNameRestricted: "timetickets"
},
work_in_progress_labour_summary: {
title: i18n.t("reportcenter.templates.work_in_progress_labour_summary"),
description: "",
subject: i18n.t("reportcenter.templates.work_in_progress_labour_summary"),
key: "work_in_progress_labour_summary",
//idtype: "vendor",
disabled: false,
rangeFilter: {
object: i18n.t("reportcenter.labels.objects.jobs"),
field: i18n.t("jobs.fields.date_open")
},
group: "jobs",
featureNameRestricted: "timetickets"
},
work_in_progress_committed_labour: {
title: i18n.t("reportcenter.templates.work_in_progress_committed_labour"),
description: "",
@@ -1746,6 +1760,20 @@ export const TemplateList = (type, context) => {
group: "jobs",
featureNameRestricted: "bills"
},
work_in_progress_payables_summary: {
title: i18n.t("reportcenter.templates.work_in_progress_payables_summary"),
description: "",
subject: i18n.t("reportcenter.templates.work_in_progress_payables_summary"),
key: "work_in_progress_payables_summary",
//idtype: "vendor",
disabled: false,
rangeFilter: {
object: i18n.t("reportcenter.labels.objects.jobs"),
field: i18n.t("jobs.fields.date_open")
},
group: "jobs",
featureNameRestricted: "bills"
},
lag_time: {
title: i18n.t("reportcenter.templates.lag_time"),
description: "",

0
localstack/init/10-bootstrap.sh Normal file → Executable file
View File

View File

@@ -130,12 +130,13 @@ exports.default = async (req, res) => {
async function QueryVendorRecord(oauthClient, qbo_realmId, req, bill) {
try {
const url = urlBuilder(
qbo_realmId,
"query",
`select * From vendor where DisplayName = '${StandardizeName(bill.vendor.name)}'`
);
const result = await oauthClient.makeApiCall({
url: urlBuilder(
qbo_realmId,
"query",
`select * From vendor where DisplayName = '${StandardizeName(bill.vendor.name)}'`
),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -150,6 +151,11 @@ async function QueryVendorRecord(oauthClient, qbo_realmId, req, bill) {
bodyshopid: bill.job.shopid,
email: req.user.email
});
logger.log("qbo-payables-query", "DEBUG", req.user.email, null, {
method: "QueryVendorRecord",
call: url,
result: result.json
});
return result.json?.QueryResponse?.Vendor?.[0];
} catch (error) {
@@ -167,8 +173,9 @@ async function InsertVendorRecord(oauthClient, qbo_realmId, req, bill) {
DisplayName: StandardizeName(bill.vendor.name)
};
try {
const url = urlBuilder(qbo_realmId, "vendor");
const result = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "vendor"),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -184,6 +191,12 @@ async function InsertVendorRecord(oauthClient, qbo_realmId, req, bill) {
bodyshopid: bill.job.shopid,
email: req.user.email
});
logger.log("qbo-payments-insert", "DEBUG", req.user.email, null, {
method: "InsertVendorRecord",
call: url,
Vendor: Vendor,
result: result.json
});
if (result.status >= 400) {
throw new Error(JSON.stringify(result.json.Fault));
@@ -274,11 +287,12 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
VendorRef: {
value: vendor.Id
},
...(vendor.TermRef && !bill.is_credit_memo && {
SalesTermRef: {
value: vendor.TermRef.value
}
}),
...(vendor.TermRef &&
!bill.is_credit_memo && {
SalesTermRef: {
value: vendor.TermRef.value
}
}),
TxnDate: moment(bill.date)
//.tz(bill.job.bodyshop.timezone)
.format("YYYY-MM-DD"),
@@ -318,8 +332,9 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
[logKey]: logValue
});
try {
const url = urlBuilder(qbo_realmId, bill.is_credit_memo ? "vendorcredit" : "bill");
const result = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, bill.is_credit_memo ? "vendorcredit" : "bill"),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -335,6 +350,12 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
bodyshopid: bill.job.shopid,
email: req.user.email
});
logger.log("qbo-payables-insert", "DEBUG", req.user.email, null, {
method: "InsertBill",
call: url,
postingObj: bill.is_credit_memo ? VendorCredit : billQbo,
result: result.json
});
if (result.status >= 400) {
throw new Error(JSON.stringify(result.json.Fault));

View File

@@ -82,14 +82,7 @@ exports.default = async (req, res) => {
if (isThreeTier || (!isThreeTier && twoTierPref === "name")) {
//Insert the name/owner and account for whether the source should be the ins co in 3 tier..
ownerCustomerTier = await QueryOwner(
oauthClient,
qbo_realmId,
req,
payment.job,
isThreeTier,
insCoCustomerTier
);
ownerCustomerTier = await QueryOwner(oauthClient, qbo_realmId, req, payment.job, insCoCustomerTier);
//Query for the owner itself.
if (!ownerCustomerTier) {
ownerCustomerTier = await InsertOwner(
@@ -229,8 +222,9 @@ async function InsertPayment(oauthClient, qbo_realmId, req, payment, parentRef)
paymentQbo
});
try {
const url = urlBuilder(qbo_realmId, "payment");
const result = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "payment"),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -246,6 +240,12 @@ async function InsertPayment(oauthClient, qbo_realmId, req, payment, parentRef)
bodyshopid: payment.job.shopid,
email: req.user.email
});
logger.log("qbo-payments-insert", "DEBUG", req.user.email, null, {
method: "InsertPayment",
call: url,
paymentQbo: paymentQbo,
result: result.json
});
if (result.status >= 400) {
throw new Error(JSON.stringify(result.json.Fault));
@@ -428,8 +428,9 @@ async function InsertCreditMemo(oauthClient, qbo_realmId, req, payment, parentRe
paymentQbo
});
try {
const url = urlBuilder(qbo_realmId, "creditmemo");
const result = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "creditmemo"),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -445,6 +446,12 @@ async function InsertCreditMemo(oauthClient, qbo_realmId, req, payment, parentRe
bodyshopid: req.user.bodyshopid,
email: req.user.email
});
logger.log("qbo-metadata-query", "DEBUG", req.user.email, null, {
method: "InsertCreditMemo",
call: url,
paymentQbo: paymentQbo,
result: result.json
});
if (result.status >= 400) {
throw new Error(JSON.stringify(result.json.Fault));

View File

@@ -213,12 +213,13 @@ exports.default = async (req, res) => {
async function QueryInsuranceCo(oauthClient, qbo_realmId, req, job) {
try {
const url = urlBuilder(
qbo_realmId,
"query",
`select * From Customer where DisplayName = '${StandardizeName(job.ins_co_nm.trim())}' and Active = true`
);
const result = await oauthClient.makeApiCall({
url: urlBuilder(
qbo_realmId,
"query",
`select * From Customer where DisplayName = '${StandardizeName(job.ins_co_nm.trim())}' and Active = true`
),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -233,6 +234,11 @@ async function QueryInsuranceCo(oauthClient, qbo_realmId, req, job) {
jobid: job.id,
email: req.user.email
});
logger.log("qbo-receivables-query", "DEBUG", req.user.email, job.id, {
method: "QueryInsuranceCo",
call: url,
result: result.json
});
return result.json?.QueryResponse?.Customer?.[0];
} catch (error) {
@@ -266,8 +272,9 @@ async function InsertInsuranceCo(oauthClient, qbo_realmId, req, job, bodyshop) {
}
};
try {
const url = urlBuilder(qbo_realmId, "customer");
const result = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "customer"),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -283,6 +290,12 @@ async function InsertInsuranceCo(oauthClient, qbo_realmId, req, job, bodyshop) {
jobid: job.id,
email: req.user.email
});
logger.log("qbo-receivables-insert", "DEBUG", req.user.email, job.id, {
method: "InsertInsuranceCo",
call: url,
customerObj: Customer,
result: result.json
});
return result.json?.Customer;
} catch (error) {
@@ -298,12 +311,13 @@ exports.InsertInsuranceCo = InsertInsuranceCo;
async function QueryOwner(oauthClient, qbo_realmId, req, job, parentTierRef) {
const ownerName = generateOwnerTier(job, true, null);
const url = urlBuilder(
qbo_realmId,
"query",
`select * From Customer where DisplayName = '${StandardizeName(ownerName)}' and Active = true`
);
const result = await oauthClient.makeApiCall({
url: urlBuilder(
qbo_realmId,
"query",
`select * From Customer where DisplayName = '${StandardizeName(ownerName)}' and Active = true`
),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -318,6 +332,11 @@ async function QueryOwner(oauthClient, qbo_realmId, req, job, parentTierRef) {
jobid: job.id,
email: req.user.email
});
logger.log("qbo-receivables-query", "DEBUG", req.user.email, job.id, {
method: "QueryOwner",
call: url,
result: result.json
});
return result.json?.QueryResponse?.Customer?.find((x) => x.ParentRef?.value === parentTierRef?.Id);
}
@@ -347,8 +366,9 @@ async function InsertOwner(oauthClient, qbo_realmId, req, job, isThreeTier, pare
: {})
};
try {
const url = urlBuilder(qbo_realmId, "customer");
const result = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "customer"),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -364,6 +384,12 @@ async function InsertOwner(oauthClient, qbo_realmId, req, job, isThreeTier, pare
jobid: job.id,
email: req.user.email
});
logger.log("qbo-receivables-insert", "DEBUG", req.user.email, job.id, {
method: "InsertOwner",
call: url,
customerObj: Customer,
result: result.json
});
return result.json?.Customer;
} catch (error) {
@@ -378,12 +404,13 @@ async function InsertOwner(oauthClient, qbo_realmId, req, job, isThreeTier, pare
exports.InsertOwner = InsertOwner;
async function QueryJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
const url = urlBuilder(
qbo_realmId,
"query",
`select * From Customer where DisplayName = '${job.ro_number}' and Active = true`
);
const result = await oauthClient.makeApiCall({
url: urlBuilder(
qbo_realmId,
"query",
`select * From Customer where DisplayName = '${job.ro_number}' and Active = true`
),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -398,6 +425,11 @@ async function QueryJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
jobid: job.id,
email: req.user.email
});
logger.log("qbo-receivables-query", "DEBUG", req.user.email, job.id, {
method: "QueryJob",
call: url,
result: result.json
});
const customers = result.json?.QueryResponse?.Customer;
return customers && (parentTierRef ? customers.find((x) => x.ParentRef.value === parentTierRef.Id) : customers[0]);
@@ -423,8 +455,9 @@ async function InsertJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
}
};
try {
const url = urlBuilder(qbo_realmId, "customer");
const result = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "customer"),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -440,6 +473,12 @@ async function InsertJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
jobid: job.id,
email: req.user.email
});
logger.log("qbo-receivables-insert", "DEBUG", req.user.email, job.id, {
method: "InsertJob",
call: url,
customerObj: Customer,
result: result.json
});
if (result.status >= 400) {
throw new Error(JSON.stringify(result.json.Fault));

View File

@@ -0,0 +1,73 @@
const { isString } = require("lodash");
const { sendServerEmail } = require("../email/sendemail");
const logger = require("../utils/logger");
const { raw } = require("express");
const SUPPORT_EMAIL = "patrick@imexsystems.ca";
const safeJsonParse = (maybeJson) => {
if (!isString(maybeJson)) return null;
try {
return JSON.parse(maybeJson);
} catch {
return null;
}
};
const handleBillAiFeedback = async (req, res) => {
try {
const rating = req.body?.rating;
const comments = isString(req.body?.comments) ? req.body?.comments?.trim() : "";
const billFormValues = safeJsonParse(req.body?.billFormValues);
const rawAIData = safeJsonParse(req.body?.rawAIData);
const jobid = billFormValues?.jobid || billFormValues?.jobId || "unknown";
const shopname = req.body?.shopname || "unknown";
const subject = `Bill AI Feedback (${rating === "up" ? "+" : "-"}) Shop=${shopname} jobid=${jobid}`;
const text = [
`User: ${req?.user?.email || "unknown"}`,
`Rating: ${rating}`,
comments ? `Comments: ${comments}` : "Comments: (none)",
"",
"Form Values (User):",
JSON.stringify(billFormValues, null, 4),
"",
"Raw AI Data:",
JSON.stringify(rawAIData, null, 4)
]
.filter(Boolean)
.join("\n");
const attachments = [];
if (req.file?.buffer) {
attachments.push({
filename: req.file.originalname || `bill-${jobid}.pdf`,
content: req.file.buffer,
contentType: req.file.mimetype || "application/pdf"
});
}
await sendServerEmail({
to: [SUPPORT_EMAIL],
subject,
type: "text",
text,
attachments
});
return res.json({ success: true });
} catch (error) {
logger.log("bill-ai-feedback-error", "ERROR", req?.user?.email, null, {
message: error?.message,
stack: error?.stack
});
return res.status(500).json({ message: "Failed to submit feedback" });
}
};
module.exports = {
handleBillAiFeedback
};

View File

@@ -1,12 +1,12 @@
const Fuse = require('fuse.js');
const { has } = require("lodash");
const { standardizedFieldsnames } = require('./bill-ocr-normalize');
const InstanceManager = require("../../utils/instanceMgr").default;
const PRICE_PERCENT_MARGIN_TOLERANCE = 0.5; //Used to make sure prices and costs are likely.
const PRICE_QUANTITY_MARGIN_TOLERANCE = 0.03; //Used to make sure that if there is a quantity, the price is likely a unit price.
// Helper function to normalize fields
const normalizePartNumber = (str) => {
return str.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
@@ -17,7 +17,38 @@ const normalizeText = (str) => {
};
const normalizePrice = (str) => {
if (typeof str !== 'string') return str;
return str.replace(/[^0-9.-]+/g, "");
let value = str.trim();
// Handle European-style decimal comma like "292,37".
// Only treat the *last* comma as a decimal separator when:
// - there's no '.' anywhere (so we don't fight normal US formatting like "1,234.56")
// - and the suffix after the last comma is 1-2 digits (so "1,234" stays 1234)
if (!value.includes('.') && value.includes(',')) {
const lastCommaIndex = value.lastIndexOf(',');
const decimalSuffix = value.slice(lastCommaIndex + 1).trim();
if (/^\d{1,2}$/.test(decimalSuffix)) {
const before = value.slice(0, lastCommaIndex).replace(/,/g, '');
value = `${before}.${decimalSuffix}`;
} else {
// Treat commas as thousands separators (or noise) and drop them.
value = value.replace(/,/g, '');
}
}
return value.replace(/[^0-9.-]+/g, "");
};
const roundToIncrement = (value, increment) => {
if (typeof value !== 'number' || !isFinite(value) || typeof increment !== 'number' || !isFinite(increment) || increment <= 0) {
return value;
}
const rounded = Math.round((value + Number.EPSILON) / increment) * increment;
// Prevent float artifacts (e.g. 0.20500000000000002)
const decimals = Math.max(0, Math.ceil(-Math.log10(increment)));
return parseFloat(rounded.toFixed(decimals));
};
//More complex function. Not necessary at the moment, keeping for reference.
@@ -134,6 +165,7 @@ const calculateTextractConfidence = (textractLineItem) => {
const hasActualCost = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.actual_cost);
const hasActualPrice = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.actual_price);
const hasLineDesc = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.line_desc);
const hasQuantity = textractLineItem?.QUANTITY?.value; //We don't normalize quantity, we just use what textract gives us.
// Calculate weighted average, giving more weight to important fields
// If we can identify key fields (ITEM, PRODUCT_CODE, PRICE), weight them higher
@@ -173,10 +205,11 @@ const calculateTextractConfidence = (textractLineItem) => {
if (!hasActualCost) missingCount++;
if (!hasActualPrice) missingCount++;
if (!hasLineDesc) missingCount++;
if (!hasQuantity) missingCount++;
// Each missing field reduces confidence by 15%
// Each missing field reduces confidence by 20%
if (missingCount > 0) {
missingFieldsPenalty = 1.0 - (missingCount * 0.15);
missingFieldsPenalty = 1.0 - (missingCount * 0.20);
}
avgConfidence = avgConfidence * missingFieldsPenalty;
@@ -361,16 +394,16 @@ async function generateBillFormData({ processedData, jobid: jobidFromProps, body
const joblineMatches = joblineFuzzySearch({ fuseToSearch: jobLineDescFuse, processedData });
const vendorFuse = new Fuse(
jobData.vendors,
jobData.vendors.map(v => ({ ...v, name_normalized: normalizeText(v.name) })),
{
keys: ['name'],
threshold: 0.4, //Adjust as needed for matching sensitivity,
keys: [{ name: "name", weight: 3 }, { name: 'name_normalized', weight: 2 }],
threshold: 0.4,
includeScore: true,
},
}
);
const vendorMatches = vendorFuse.search(processedData.summary?.VENDOR_NAME?.value || processedData.summary?.NAME?.value);
const vendorMatches = vendorFuse.search(normalizeText(processedData.summary?.VENDOR_NAME?.value || processedData.summary?.NAME?.value));
let vendorid;
if (vendorMatches.length > 0) {
@@ -381,6 +414,21 @@ async function generateBillFormData({ processedData, jobid: jobidFromProps, body
throw new Error('Job not found for bill form data generation.');
}
//Is there a subtotal level discount? If there is, we need to figure out what the percentage is, and apply that to the actual cost as a reduction
const subtotalDiscountValueRaw = processedData.summary?.DISCOUNT?.value || processedData.summary?.SUBTOTAL_DISCOUNT?.value || 0;
let discountPercentageDecimal = 0;
if (subtotalDiscountValueRaw) {
const subtotal = parseFloat(normalizePrice(processedData.summary?.SUBTOTAL?.value || 0)) || 0;
const subtotalDiscountValue = parseFloat(normalizePrice(subtotalDiscountValueRaw)) || 0;
if (subtotal > 0 && subtotalDiscountValue) {
// Store discount percentage as a decimal (e.g. 20.5% => 0.205),
// but only allow half-percent increments (0.005 steps).
discountPercentageDecimal = Math.abs(subtotalDiscountValue / subtotal);
discountPercentageDecimal = roundToIncrement(discountPercentageDecimal, 0.005);
}
}
//TODO: How do we handle freight lines and core charges?
//Create the form data structure for the bill posting screen.
const billFormData = {
@@ -448,6 +496,31 @@ async function generateBillFormData({ processedData, jobid: jobidFromProps, body
}
}
//If there's nothing, just fall back to seeing if there's a price object from textract.
if (!actualPrice && textractLineItem.PRICE) {
actualPrice = textractLineItem.PRICE.value;
}
if (!actualCost && textractLineItem.PRICE) {
actualCost = textractLineItem.PRICE.value;
}
//If quantity greater than 1, check if the actual cost is a multiple of the actual price, if so, divide it out to get the unit price.
const quantity = parseInt(textractLineItem?.QUANTITY?.value);
if (quantity && quantity > 1) {
if (actualPrice && quantity && Math.abs((actualPrice / quantity) - (parseFloat(matchToUse?.item?.act_price) || 0)) / ((parseFloat(matchToUse?.item?.act_price) || 1)) < PRICE_QUANTITY_MARGIN_TOLERANCE) {
actualPrice = actualPrice / quantity;
}
if (actualCost && quantity && Math.abs((actualCost / quantity) - (parseFloat(matchToUse?.item?.act_price) || 0)) / ((parseFloat(matchToUse?.item?.act_price) || 1)) < PRICE_QUANTITY_MARGIN_TOLERANCE) {
actualCost = actualCost / quantity;
}
}
if (discountPercentageDecimal > 0) {
actualCost = actualCost * (1 - discountPercentageDecimal);
}
const responsibilityCenters = job.bodyshop.md_responsibility_centers
//TODO: Do we need to verify the lines to see if it is a unit price or total price (i.e. quantity * price)
const lineObject = {
@@ -714,5 +787,6 @@ const bodyshopHasDmsKey = (bodyshop) =>
module.exports = {
generateBillFormData
generateBillFormData,
normalizePrice
}

View File

@@ -50,10 +50,12 @@ function normalizeLabelName(labelText) {
'unit_price': standardizedFieldsnames.actual_price,
'list': standardizedFieldsnames.actual_price,
'retail_price': standardizedFieldsnames.actual_price,
'retail': standardizedFieldsnames.actual_price,
'net': standardizedFieldsnames.actual_cost,
'selling_price': standardizedFieldsnames.actual_cost,
'net_price': standardizedFieldsnames.actual_cost,
'net_cost': standardizedFieldsnames.actual_cost,
'total': standardizedFieldsnames.actual_cost,
'po_no': standardizedFieldsnames.ro_number,
'customer_po_no': standardizedFieldsnames.ro_number,
'customer_po_no_': standardizedFieldsnames.ro_number

View File

@@ -6,6 +6,7 @@ const { getTextractJobKey, setTextractJob, getTextractJob, getFileType, getPdfPa
const { extractInvoiceData, processScanData } = require("./bill-ocr-normalize");
const { generateBillFormData } = require("./bill-ocr-generator");
const logger = require("../../utils/logger");
const _ = require("lodash");
// Initialize AWS clients
const awsConfig = {
@@ -66,7 +67,7 @@ async function handleBillOcr(req, res) {
if (fileType === 'image') {
const processedData = await processSinglePageDocument(uploadedFile.buffer);
const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: req });
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ...processedData, billForm });
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ..._.omit(processedData, "originalTextractResponse"), billForm });
return res.status(200).json({
success: true,
@@ -82,7 +83,7 @@ async function handleBillOcr(req, res) {
// Process synchronously for single-page documents
const processedData = await processSinglePageDocument(uploadedFile.buffer);
const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: req });
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ...processedData, billForm });
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ..._.omit(processedData, "originalTextractResponse"), billForm });
return res.status(200).json({
success: true,
status: 'COMPLETED',
@@ -211,7 +212,8 @@ async function processSinglePageDocument(pdfBuffer) {
return {
...processedData,
originalTextractResponse: result
//Removed as this is a large object that provides minimal value to send to client.
// originalTextractResponse: result
};
}
@@ -391,7 +393,8 @@ async function handleTextractNotification(message) {
status: 'COMPLETED',
data: {
...processedData,
originalTextractResponse: originalResponse
//Removed as this is a large object that provides minimal value to send to client.
// originalTextractResponse: originalResponse
},
completedAt: new Date().toISOString()
}

View File

@@ -66,7 +66,12 @@ exports.default = async function ReloadCdkMakes(req, res) {
} catch (error) {
logger.log("cdk-replace-makes-models-error", "ERROR", req.user.email, null, {
cdk_dealerid,
error
error: {
message: error?.message,
stack: error?.stack,
name: error?.name,
code: error?.code
}
});
res.status(500).json(error);
}
@@ -105,7 +110,12 @@ async function GetCdkMakes(req, cdk_dealerid) {
} catch (error) {
logger.log("cdk-replace-makes-models-error", "ERROR", req.user.email, null, {
cdk_dealerid,
error
error: {
message: error?.message,
stack: error?.stack,
name: error?.name,
code: error?.code
}
});
throw new Error(error);
@@ -141,7 +151,12 @@ async function GetFortellisMakes(req, cdk_dealerid) {
} catch (error) {
logger.log("fortellis-replace-makes-models-error", "ERROR", req.user.email, null, {
cdk_dealerid,
error
error: {
message: error?.message,
stack: error?.stack,
name: error?.name,
code: error?.code
}
});
throw new Error(error);

View File

@@ -250,6 +250,8 @@ const CreateRepairOrderTag = (job, errorCallback) => {
},
InsuranceCompany: job.ins_co_nm || "",
Claim: job.clm_no || "",
Deductible: job.ded_amt || 0,
PolicyNo: job.policy_no || "",
DMSAllocation: job.dms_allocation || "",
Contacts: {
CSR: job.employee_csr_rel

View File

@@ -44,7 +44,7 @@ const logEmail = async (req, email) => {
}
};
const sendServerEmail = async ({ subject, text, to = [] }) => {
const sendServerEmail = async ({ subject, text, to = [], attachments }) => {
if (process.env.NODE_ENV === undefined) return;
try {
@@ -57,6 +57,7 @@ const sendServerEmail = async ({ subject, text, to = [] }) => {
to: ["support@imexsystems.ca", ...to],
subject: subject,
text: text,
attachments: attachments,
ses: {
// optional extra arguments for SendRawEmail
Tags: [

View File

@@ -959,7 +959,7 @@ async function UpdateDmsVehicle({ socket, redisHelpers, JobData, DMSVeh, DMSCust
delete DMSVehToSend.inventoryAccount;
!DMSVehToSend.vehicle.engineNumber && delete DMSVehToSend.vehicle.engineNumber;
!DMSVehToSend.vehicle.saleClassValue && DMSVehToSend.vehicle.saleClassValue === "MISC";
!DMSVehToSend.vehicle.saleClassValue && (DMSVehToSend.vehicle.saleClassValue = "MISC");
!DMSVehToSend.vehicle.exteriorColor && delete DMSVehToSend.vehicle.exteriorColor;
const result = await MakeFortellisCall({

View File

@@ -1285,6 +1285,7 @@ exports.KAIZEN_QUERY = `query KAIZEN_EXPORT($start: timestamptz, $bodyshopid: uu
date_repairstarted
date_void
dms_allocation
ded_amt
employee_body_rel {
first_name
last_name
@@ -1380,6 +1381,7 @@ exports.KAIZEN_QUERY = `query KAIZEN_EXPORT($start: timestamptz, $bodyshopid: uu
}
parts_tax_rates
plate_no
policy_no
rate_la1
rate_la2
rate_la3

View File

@@ -315,7 +315,12 @@ function CalculateRatesTotals(ratesList) {
if (item.mod_lbr_ty) {
//Check to see if it has 0 hours and a price instead.
//Extend for when there are hours and a price.
if (item.lbr_op === "OP14" && item.act_price > 0 && (!item.part_type || item.mod_lb_hrs === 0) && !IsAdditionalCost(item)) {
if (
item.lbr_op === "OP14" &&
item.act_price > 0 &&
(!item.part_type || item.mod_lb_hrs === 0) &&
!IsAdditionalCost(item)
) {
//Scenario where SGI may pay out hours using a part price.
if (!ret[item.mod_lbr_ty.toLowerCase()].total) {
ret[item.mod_lbr_ty.toLowerCase()].total = Dinero();
@@ -339,38 +344,30 @@ function CalculateRatesTotals(ratesList) {
let subtotal = Dinero({ amount: 0 });
let rates_subtotal = Dinero({ amount: 0 });
for (const property in ret) {
for (const [property, values] of Object.entries(ret)) {
//Skip calculating mapa and mash if we got the amounts.
if (!((property === "mapa" && hasMapaLine) || (property === "mash" && hasMashLine))) {
if (!ret[property].total) {
ret[property].total = Dinero();
}
let threshold;
//Check if there is a max for this type.
if (ratesList.materials && ratesList.materials[property]) {
//
if (ratesList.materials[property].cal_maxdlr && ratesList.materials[property].cal_maxdlr > 0) {
//It has an upper threshhold.
threshold = Dinero({
amount: Math.round(ratesList.materials[property].cal_maxdlr * 100)
});
}
}
const shouldSkipCalculation = (property === "mapa" && hasMapaLine) || (property === "mash" && hasMashLine);
if (!shouldSkipCalculation) {
values.total ??= Dinero();
//Check if there is a max for this type and apply it.
const maxDollar =
ratesList.materials?.[property]?.cal_maxdlr || ratesList.materials?.[property.toUpperCase()]?.cal_maxdlr;
const threshold = maxDollar > 0 ? Dinero({ amount: Math.round(maxDollar * 100) }) : null;
const total = Dinero({
amount: Math.round((ret[property].rate || 0) * 100)
}).multiply(ret[property].hours);
amount: Math.round((values.rate || 0) * 100)
}).multiply(values.hours);
if (threshold && total.greaterThanOrEqual(threshold)) {
ret[property].total = ret[property].total.add(threshold);
} else {
ret[property].total = ret[property].total.add(total);
}
values.total = values.total.add(threshold && total.greaterThanOrEqual(threshold) ? threshold : total);
}
subtotal = subtotal.add(ret[property].total);
subtotal = subtotal.add(values.total);
if (property !== "mapa" && property !== "mash") rates_subtotal = rates_subtotal.add(ret[property].total);
if (property !== "mapa" && property !== "mash") {
rates_subtotal = rates_subtotal.add(values.total);
}
}
ret.subtotal = subtotal;

View File

@@ -4,9 +4,14 @@ const multer = require("multer");
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware");
const { handleBillOcr, handleBillOcrStatus } = require("../ai/bill-ocr/bill-ocr");
const { handleBillAiFeedback } = require("../ai/bill-ai-feedback");
// Configure multer for form data parsing
const upload = multer();
// Configure multer for form data parsing (memory storage)
const upload = multer({
limits: {
fileSize: 5 * 1024 * 1024 // 5MB
}
});
router.use(validateFirebaseIdTokenMiddleware);
router.use(withUserGraphQLClientMiddleware);
@@ -14,4 +19,6 @@ router.use(withUserGraphQLClientMiddleware);
router.post("/bill-ocr", upload.single('billScan'), handleBillOcr);
router.get("/bill-ocr/status/:textractJobId", handleBillOcrStatus);
router.post("/bill-feedback", upload.single("billPdf"), handleBillAiFeedback);
module.exports = router;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,7 @@
const { RRClient } = require("./lib/index.cjs");
const { getRRConfigFromBodyshop } = require("./rr-config");
const CreateRRLogEvent = require("./rr-logger-event");
const { withRRRequestXml } = require("./rr-log-xml");
const InstanceManager = require("../utils/instanceMgr").default;
/**
@@ -217,14 +218,24 @@ const createRRCustomer = async ({ bodyshop, job, overrides = {}, socket }) => {
try {
response = await client.insertCustomer(safePayload, opts);
// Very noisy; only show when log level is cranked to SILLY
CreateRRLogEvent(socket, "SILLY", "{CU} insertCustomer: raw response", { response });
CreateRRLogEvent(
socket,
"SILLY",
"{CU} insertCustomer: raw response",
withRRRequestXml(response, { response })
);
} catch (e) {
CreateRRLogEvent(socket, "ERROR", "RR insertCustomer transport error", {
message: e?.message,
code: e?.code,
status: e?.meta?.status || e?.status,
payload: safePayload
});
CreateRRLogEvent(
socket,
"ERROR",
"RR insertCustomer transport error",
withRRRequestXml(e, {
message: e?.message,
code: e?.code,
status: e?.meta?.status || e?.status,
payload: safePayload
})
);
throw e;
}
@@ -233,12 +244,17 @@ const createRRCustomer = async ({ bodyshop, job, overrides = {}, socket }) => {
let customerNo = data?.dmsRecKey;
if (!customerNo) {
CreateRRLogEvent(socket, "ERROR", "RR insertCustomer returned no dmsRecKey/custNo", {
status: trx?.status,
statusCode: trx?.statusCode,
message: trx?.message,
data
});
CreateRRLogEvent(
socket,
"ERROR",
"RR insertCustomer returned no dmsRecKey/custNo",
withRRRequestXml(response, {
status: trx?.status,
statusCode: trx?.statusCode,
message: trx?.message,
data
})
);
throw new Error(
`RR insertCustomer returned no dmsRecKey (status=${trx?.status ?? "?"} code=${trx?.statusCode ?? "?"}${

View File

@@ -4,7 +4,7 @@
* @returns {number|null}
*/
const parseVendorStatusCode = (err) => {
// Prefer explicit numeric props when available
// Prefer explicit numeric props when available.
const codeProp = err?.code ?? err?.statusCode ?? err?.meta?.status?.StatusCode ?? err?.status?.StatusCode;
const num = Number(codeProp);
if (!Number.isNaN(num) && num > 0) return num;

View File

@@ -1,6 +1,7 @@
const { GraphQLClient } = require("graphql-request");
const queries = require("../graphql-client/queries");
const CreateRRLogEvent = require("./rr-logger-event");
const { extractRRXmlPair } = require("./rr-log-xml");
/** Get bearer token from the socket (same approach used elsewhere) */
const getAuthToken = (socket) =>
@@ -178,11 +179,23 @@ const insertRRFailedExportLog = async ({ socket, jobId, job, bodyshop, error, cl
const client = new GraphQLClient(endpoint, {});
client.setHeaders({ Authorization: `Bearer ${token}` });
const { requestXml, responseXml } = extractRRXmlPair(error);
const xmlFromError =
requestXml || responseXml
? {
...(requestXml ? { request: requestXml } : {}),
...(responseXml ? { response: responseXml } : {})
}
: undefined;
const meta = buildRRExportMeta({
result,
extra: {
error: error?.message || String(error),
classification: classification || undefined
classification: classification || undefined,
...(requestXml ? { requestXml } : {}),
...(responseXml ? { responseXml } : {}),
...(xmlFromError && !result?.xml ? { xml: xmlFromError } : {})
}
});

View File

@@ -1,6 +1,7 @@
const { buildRRRepairOrderPayload } = require("./rr-job-helpers");
const { buildClientAndOpts } = require("./rr-lookup");
const CreateRRLogEvent = require("./rr-logger-event");
const { withRRRequestXml } = require("./rr-log-xml");
const { extractRrResponsibilityCenters } = require("./rr-responsibility-centers");
const CdkCalculateAllocations = require("./rr-calculate-allocations").default;
const { resolveRROpCodeFromBodyshop } = require("./rr-utils");
@@ -147,10 +148,7 @@ const createMinimalRRRepairOrder = async (args) => {
const response = await client.createRepairOrder(payload, finalOpts);
CreateRRLogEvent(socket, "INFO", "RR minimal Repair Order created", {
payload,
response
});
CreateRRLogEvent(socket, "INFO", "RR minimal Repair Order created", withRRRequestXml(response, { payload, response }));
const data = response?.data || null;
const statusBlocks = response?.statusBlocks || {};
@@ -327,7 +325,7 @@ const updateRRRepairOrderWithFullData = async (args) => {
// 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)", {
CreateRRLogEvent(socket, "INFO", "Preparing full data for early RO (using create with roNo)", {
roNo: String(roNo),
hasRolabor: !!payload.rolabor,
hasRogg: !!payload.rogg,
@@ -338,10 +336,18 @@ const updateRRRepairOrderWithFullData = async (args) => {
// 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
});
CreateRRLogEvent(
socket,
"INFO",
"Sending full data for early RO (using create with roNo)",
withRRRequestXml(response, {
roNo: String(roNo),
hasRolabor: !!payload.rolabor,
hasRogg: !!payload.rogg,
payload,
response
})
);
const data = response?.data || null;
const statusBlocks = response?.statusBlocks || {};
@@ -501,10 +507,7 @@ const exportJobToRR = async (args) => {
const response = await client.createRepairOrder(payload, finalOpts);
CreateRRLogEvent(socket, "INFO", "RR raw Repair Order created", {
payload,
response
});
CreateRRLogEvent(socket, "INFO", "RR raw Repair Order created", withRRRequestXml(response, { payload, response }));
const data = response?.data || null;
const statusBlocks = response?.statusBlocks || {};
@@ -603,10 +606,15 @@ const finalizeRRRepairOrder = async (args) => {
const rrRes = await client.updateRepairOrder(payload, finalOpts);
CreateRRLogEvent(socket, "SILLY", "RR Repair Order finalized", {
payload,
response: rrRes
});
CreateRRLogEvent(
socket,
"SILLY",
"RR Repair Order finalized",
withRRRequestXml(rrRes, {
payload,
response: rrRes
})
);
const data = rrRes?.data || null;
const statusBlocks = rrRes?.statusBlocks || {};

63
server/rr/rr-log-xml.js Normal file
View File

@@ -0,0 +1,63 @@
/**
* Extract request/response XML from RR response/result shapes.
* @param rrObj
* @returns {{requestXml: string|null, responseXml: string|null}}
*/
const extractRRXmlPair = (rrObj) => {
const xml = rrObj?.xml ?? rrObj?.meta?.xml;
let requestXml = null;
let responseXml = null;
if (typeof xml === "string") {
requestXml = xml;
} else {
if (typeof xml?.request === "string") requestXml = xml.request;
else if (typeof xml?.req === "string") requestXml = xml.req;
else if (typeof xml?.starXml === "string") requestXml = xml.starXml;
if (typeof xml?.response === "string") responseXml = xml.response;
}
if (!requestXml && typeof rrObj?.requestXml === "string") requestXml = rrObj.requestXml;
if (!requestXml && typeof rrObj?.meta?.requestXml === "string") requestXml = rrObj.meta.requestXml;
if (!requestXml && typeof rrObj?.meta?.reqXml === "string") requestXml = rrObj.meta.reqXml;
if (!requestXml && typeof rrObj?.meta?.request === "string") requestXml = rrObj.meta.request;
if (!responseXml && typeof rrObj?.responseXml === "string") responseXml = rrObj.responseXml;
if (!responseXml && typeof rrObj?.meta?.responseXml === "string") responseXml = rrObj.meta.responseXml;
if (!responseXml && typeof rrObj?.meta?.resXml === "string") responseXml = rrObj.meta.resXml;
if (!responseXml && typeof rrObj?.meta?.response === "string") responseXml = rrObj.meta.response;
// If wrapped HTTP response data contains raw XML, surface it.
if (!responseXml && typeof rrObj?.response?.data === "string") {
const xmlData = rrObj.response.data.trim();
if (xmlData.startsWith("<")) responseXml = xmlData;
}
// Try one level down when errors are wrapped.
if ((!requestXml || !responseXml) && rrObj?.cause && rrObj.cause !== rrObj) {
const nested = extractRRXmlPair(rrObj.cause);
if (!requestXml) requestXml = nested.requestXml;
if (!responseXml) responseXml = nested.responseXml;
}
return { requestXml, responseXml };
};
/**
* Add Reynolds request/response XML to RR log metadata when available.
* @param rrObj
* @param meta
* @returns {*}
*/
const withRRRequestXml = (rrObj, meta = {}) => {
const { requestXml, responseXml } = extractRRXmlPair(rrObj);
const xmlMeta = {};
if (requestXml) xmlMeta.requestXml = requestXml;
if (responseXml) xmlMeta.responseXml = responseXml;
return Object.keys(xmlMeta).length ? { ...meta, ...xmlMeta } : meta;
};
module.exports = {
extractRRXmlPair,
withRRRequestXml
};

View File

@@ -12,6 +12,7 @@ const { createRRCustomer } = require("./rr-customers");
const { ensureRRServiceVehicle } = require("./rr-service-vehicles");
const { classifyRRVendorError } = require("./rr-errors");
const { markRRExportSuccess, insertRRFailedExportLog } = require("./rr-export-logs");
const { withRRRequestXml, extractRRXmlPair } = require("./rr-log-xml");
const {
makeVehicleSearchPayloadFromJob,
ownersFromVinBlocks,
@@ -48,6 +49,21 @@ const resolveJobId = (explicit, payload, job) => explicit || payload?.jobId || j
*/
const resolveVin = ({ tx, job }) => tx?.jobData?.vin || job?.v_vin || null;
/**
* Add request/response XML to socket event payloads when available.
* @param rrObj
* @param payload
* @returns {*}
*/
const withRRXmlSocketPayload = (rrObj, payload = {}) => {
const { requestXml, responseXml } = extractRRXmlPair(rrObj);
return {
...payload,
...(requestXml ? { requestXml } : {}),
...(responseXml ? { responseXml } : {})
};
};
/**
* Sort vehicle owners first in the list, preserving original order otherwise.
* @param list
@@ -154,15 +170,13 @@ const setJobDmsIdForSocket = async ({ socket, jobId, dmsId, dmsCustomerId, dmsAd
if (!token) throw new Error("Missing auth token for setJobDmsIdForSocket");
const client = new GraphQLClient(endpoint, {});
await client
.setHeaders({ Authorization: `Bearer ${token}` })
.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
});
await client.setHeaders({ Authorization: `Bearer ${token}` }).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,
@@ -241,7 +255,12 @@ const rrMultiCustomerSearch = async ({ bodyshop, job, socket, redisHelpers }) =>
const multiResponse = await rrCombinedSearch(bodyshop, q);
CreateRRLogEvent(socket, "SILLY", "Multi Customer Search - raw combined search", { response: multiResponse });
CreateRRLogEvent(
socket,
"SILLY",
"Multi Customer Search - raw combined search",
withRRRequestXml(multiResponse, { response: multiResponse })
);
if (fromVin) {
const multiBlocks = Array.isArray(multiResponse?.data) ? multiResponse.data : [];
@@ -262,7 +281,7 @@ const rrMultiCustomerSearch = async ({ bodyshop, job, socket, redisHelpers }) =>
const norm = normalizeCustomerCandidates(multiResponse, { ownersSet });
merged.push(...norm);
} catch (e) {
CreateRRLogEvent(socket, "WARN", "Multi-search subquery failed", { kind: q.kind, error: e.message });
CreateRRLogEvent(socket, "WARN", "Multi-search subquery failed", withRRRequestXml(e, { kind: q.kind, error: e.message }));
}
}
@@ -310,7 +329,7 @@ const registerRREvents = ({ socket, redisHelpers }) => {
count: decorated.length
});
} catch (e) {
CreateRRLogEvent(socket, "ERROR", "RR combined lookup error", { error: e.message, jobid });
CreateRRLogEvent(socket, "ERROR", "RR combined lookup error", withRRRequestXml(e, { error: e.message, jobid }));
cb?.({ jobid, error: e.message });
}
});
@@ -387,7 +406,7 @@ const registerRREvents = ({ socket, redisHelpers }) => {
fromCache
});
} catch (err) {
CreateRRLogEvent(socket, "ERROR", "rr-get-advisors: failed", { error: err?.message });
CreateRRLogEvent(socket, "ERROR", "rr-get-advisors: failed", withRRRequestXml(err, { error: err?.message }));
ack?.({ ok: false, error: err?.message || "get advisors failed" });
}
});
@@ -458,14 +477,26 @@ const registerRREvents = ({ socket, redisHelpers }) => {
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
});
CreateRRLogEvent(
socket,
"ERROR",
`Error during RR early RO creation (prepare)`,
withRRRequestXml(error, {
error: error.message,
stack: error.stack,
jobid: rid
})
);
try {
socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message });
socket.emit(
"export-failed",
withRRXmlSocketPayload(error, {
vendor: "rr",
jobId: rid,
error: error.message
})
);
} catch {
//
}
@@ -511,7 +542,11 @@ const registerRREvents = ({ socket, redisHelpers }) => {
});
// Filter out invalid values
if (selectedCustNo === "undefined" || selectedCustNo === "null" || (selectedCustNo && selectedCustNo.trim() === "")) {
if (
selectedCustNo === "undefined" ||
selectedCustNo === "null" ||
(selectedCustNo && selectedCustNo.trim() === "")
) {
selectedCustNo = null;
}
@@ -555,7 +590,12 @@ const registerRREvents = ({ socket, redisHelpers }) => {
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 });
CreateRRLogEvent(
socket,
"SILLY",
`VIN owner pre-check response (early RO)`,
withRRRequestXml(vinResponse, { response: vinResponse })
);
const vinBlocks = Array.isArray(vinResponse?.data) ? vinResponse.data : [];
@@ -588,9 +628,14 @@ const registerRREvents = ({ socket, redisHelpers }) => {
}
}
} catch (e) {
CreateRRLogEvent(socket, "WARN", `VIN owner pre-check failed; continuing with selected customer (early RO)`, {
error: e?.message
});
CreateRRLogEvent(
socket,
"WARN",
`VIN owner pre-check failed; continuing with selected customer (early RO)`,
withRRRequestXml(e, {
error: e?.message
})
);
}
// Cache final/effective customer selection
@@ -705,42 +750,52 @@ const registerRREvents = ({ socket, redisHelpers }) => {
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
});
CreateRRLogEvent(
socket,
"DEBUG",
"Early RO created - checking dmsRoNo",
withRRRequestXml(result, {
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,
CreateRRLogEvent(socket, "DEBUG", "Calling setJobDmsIdForSocket", {
jobId: rid,
dmsId: dmsRoNo,
customerId: effectiveCustNo,
advisorId: String(advisorNo),
mileageIn
});
await setJobDmsIdForSocket({
socket,
jobId: rid,
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
CreateRRLogEvent(
socket,
"WARN",
"RR early RO creation succeeded but no DMS RO number was returned",
withRRRequestXml(result, {
jobId: rid,
resultPreview: {
roNo: result?.roNo,
data: {
dmsRoNo: data?.dmsRoNo,
outsdRoNo: data?.outsdRoNo
}
}
}
});
})
);
}
await redisHelpers.setSessionTransactionData(
@@ -758,10 +813,15 @@ const registerRREvents = ({ socket, redisHelpers }) => {
defaultRRTTL
);
CreateRRLogEvent(socket, "INFO", `{EARLY-5} Minimal RO created successfully`, {
dmsRoNo: dmsRoNo || null,
outsdRoNo: outsdRoNo || null
});
CreateRRLogEvent(
socket,
"INFO",
`{EARLY-5} Minimal RO created successfully`,
withRRRequestXml(result, {
dmsRoNo: dmsRoNo || null,
outsdRoNo: outsdRoNo || null
})
);
// Mark success in export logs
await markRRExportSuccess({
@@ -810,11 +870,16 @@ const registerRREvents = ({ socket, redisHelpers }) => {
message: vendorMessage
});
CreateRRLogEvent(socket, "ERROR", `Early RO creation failed`, {
roStatus: result?.roStatus,
statusBlocks: result?.statusBlocks,
classification: cls
});
CreateRRLogEvent(
socket,
"ERROR",
`Early RO creation failed`,
withRRRequestXml(result, {
roStatus: result?.roStatus,
statusBlocks: result?.statusBlocks,
classification: cls
})
);
await insertRRFailedExportLog({
socket,
@@ -827,9 +892,11 @@ const registerRREvents = ({ socket, redisHelpers }) => {
});
socket.emit("export-failed", {
vendor: "rr",
jobId: rid,
error: cls?.friendlyMessage || result?.error || "RR early RO creation failed",
...withRRXmlSocketPayload(result, {
vendor: "rr",
jobId: rid,
error: cls?.friendlyMessage || result?.error || "RR early RO creation failed"
}),
...cls
});
@@ -843,14 +910,19 @@ const registerRREvents = ({ socket, redisHelpers }) => {
} 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
});
CreateRRLogEvent(
socket,
"ERROR",
`Error during RR early RO creation (customer-selected)`,
withRRRequestXml(error, {
error: error.message,
vendorStatusCode: cls.vendorStatusCode,
code: cls.errorCode,
friendly: cls.friendlyMessage,
stack: error.stack,
jobid: rid
})
);
try {
if (!bodyshop || !job) {
@@ -875,9 +947,11 @@ const registerRREvents = ({ socket, redisHelpers }) => {
try {
socket.emit("export-failed", {
vendor: "rr",
jobId: rid,
error: error.message,
...withRRXmlSocketPayload(error, {
vendor: "rr",
jobId: rid,
error: error.message
}),
...cls
});
socket.emit("rr-user-notice", { jobId: rid, ...cls });
@@ -940,14 +1014,14 @@ const registerRREvents = ({ socket, redisHelpers }) => {
// 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(
@@ -967,18 +1041,18 @@ const registerRREvents = ({ socket, redisHelpers }) => {
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;
@@ -1011,7 +1085,12 @@ const registerRREvents = ({ socket, redisHelpers }) => {
redisHelpers
});
const advisorNo = job.dms_advisor_id || readAdvisorNo({ txEnvelope }, await redisHelpers.getSessionTransactionData(socket.id, getTransactionType(rid), RRCacheEnums.AdvisorNo));
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).");
@@ -1030,7 +1109,28 @@ const registerRREvents = ({ socket, redisHelpers }) => {
roNo: job.dms_id
});
CreateRRLogEvent(
socket,
"SILLY",
"{4.1} RR RO update response received",
withRRRequestXml(result, {
dmsRoNo: job.dms_id,
success: !!result?.success
})
);
if (!result?.success) {
CreateRRLogEvent(
socket,
"ERROR",
"RR Repair Order update failed",
withRRRequestXml(result, {
jobId: rid,
dmsRoNo: job.dms_id,
roStatus: result?.roStatus,
statusBlocks: result?.statusBlocks
})
);
throw new Error(result?.roStatus?.message || "Failed to update RR Repair Order");
}
@@ -1059,15 +1159,20 @@ const registerRREvents = ({ socket, redisHelpers }) => {
defaultRRTTL
);
CreateRRLogEvent(socket, "INFO", `RR Repair Order updated successfully`, {
dmsRoNo,
jobId: rid
});
CreateRRLogEvent(
socket,
"INFO",
`RR Repair Order updated successfully`,
withRRRequestXml(result, {
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 });
}
@@ -1082,14 +1187,26 @@ const registerRREvents = ({ socket, redisHelpers }) => {
anyOwner: decorated.some((c) => c.vinOwner || c.isVehicleOwner)
});
} catch (error) {
CreateRRLogEvent(socket, "ERROR", `Error during RR export (prepare)`, {
error: error.message,
stack: error.stack,
jobid: rid
});
CreateRRLogEvent(
socket,
"ERROR",
`Error during RR export (prepare)`,
withRRRequestXml(error, {
error: error.message,
stack: error.stack,
jobid: rid
})
);
try {
socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message });
socket.emit(
"export-failed",
withRRXmlSocketPayload(error, {
vendor: "rr",
jobId: rid,
error: error.message
})
);
} catch {
//
}
@@ -1148,7 +1265,12 @@ const registerRREvents = ({ socket, redisHelpers }) => {
if (vehQ && vehQ.kind === "vin" && job?.v_vin) {
const vinResponse = await rrCombinedSearch(bodyshop, vehQ);
CreateRRLogEvent(socket, "SILLY", `VIN owner pre-check response`, { response: vinResponse });
CreateRRLogEvent(
socket,
"SILLY",
`VIN owner pre-check response`,
withRRRequestXml(vinResponse, { response: vinResponse })
);
const vinBlocks = Array.isArray(vinResponse?.data) ? vinResponse.data : [];
@@ -1181,9 +1303,14 @@ const registerRREvents = ({ socket, redisHelpers }) => {
}
}
} catch (e) {
CreateRRLogEvent(socket, "WARN", `VIN owner pre-check failed; continuing with selected customer`, {
error: e?.message
});
CreateRRLogEvent(
socket,
"WARN",
`VIN owner pre-check failed; continuing with selected customer`,
withRRRequestXml(e, {
error: e?.message
})
);
}
// Cache final/effective customer selection
@@ -1277,25 +1404,25 @@ const registerRREvents = ({ socket, redisHelpers }) => {
// 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`, {
CreateRRLogEvent(socket, "DEBUG", `Using stored customer ID from early RO`, {
storedCustomerId: job.dms_customer_id,
originalCustomerId: effectiveCustNo
originalCustomerId: effectiveCustNo
});
finalEffectiveCustNo = String(job.dms_customer_id);
}
if (shouldUpdate && job?.dms_advisor_id) {
CreateRRLogEvent(socket, "DEBUG", `Using stored advisor ID from early RO`, {
CreateRRLogEvent(socket, "DEBUG", `Using stored advisor ID from early RO`, {
storedAdvisorId: job.dms_advisor_id,
originalAdvisorId: advisorNo
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 });
@@ -1344,16 +1471,21 @@ const registerRREvents = ({ socket, redisHelpers }) => {
if (dmsRoNo) {
await setJobDmsIdForSocket({ socket, jobId: rid, dmsId: dmsRoNo });
} else {
CreateRRLogEvent(socket, "WARN", "RR export succeeded but no DMS RO number was returned", {
jobId: rid,
resultPreview: {
roNo: result?.roNo,
data: {
dmsRoNo: data?.dmsRoNo,
outsdRoNo: data?.outsdRoNo
CreateRRLogEvent(
socket,
"WARN",
"RR export succeeded but no DMS RO number was returned",
withRRRequestXml(result, {
jobId: rid,
resultPreview: {
roNo: result?.roNo,
data: {
dmsRoNo: data?.dmsRoNo,
outsdRoNo: data?.outsdRoNo
}
}
}
});
})
);
}
await redisHelpers.setSessionTransactionData(
@@ -1370,10 +1502,15 @@ const registerRREvents = ({ socket, redisHelpers }) => {
defaultRRTTL
);
CreateRRLogEvent(socket, "INFO", `{5} RO created. Waiting for validation.`, {
dmsRoNo: dmsRoNo || null,
outsdRoNo: outsdRoNo || null
});
CreateRRLogEvent(
socket,
"INFO",
`{5} RO created. Waiting for validation.`,
withRRRequestXml(result, {
dmsRoNo: dmsRoNo || null,
outsdRoNo: outsdRoNo || null
})
);
// Tell FE to prompt for "Finished/Close"
socket.emit("rr-validation-required", { jobId: rid, dmsRoNo, outsdRoNo });
@@ -1412,11 +1549,16 @@ const registerRREvents = ({ socket, redisHelpers }) => {
message: vendorMessage
});
CreateRRLogEvent(socket, "ERROR", `Export failed (step 1)`, {
roStatus: result?.roStatus,
statusBlocks: result?.statusBlocks,
classification: cls
});
CreateRRLogEvent(
socket,
"ERROR",
`Export failed (step 1)`,
withRRRequestXml(result, {
roStatus: result?.roStatus,
statusBlocks: result?.statusBlocks,
classification: cls
})
);
await insertRRFailedExportLog({
socket,
@@ -1429,9 +1571,11 @@ const registerRREvents = ({ socket, redisHelpers }) => {
});
socket.emit("export-failed", {
vendor: "rr",
jobId: rid,
error: cls?.friendlyMessage || result?.error || "RR export failed",
...withRRXmlSocketPayload(result, {
vendor: "rr",
jobId: rid,
error: cls?.friendlyMessage || result?.error || "RR export failed"
}),
...cls
});
@@ -1445,14 +1589,19 @@ const registerRREvents = ({ socket, redisHelpers }) => {
} catch (error) {
const cls = classifyRRVendorError(error);
CreateRRLogEvent(socket, "ERROR", `Error during RR export (selected-customer)`, {
error: error.message,
vendorStatusCode: cls.vendorStatusCode,
code: cls.errorCode,
friendly: cls.friendlyMessage,
stack: error.stack,
jobid: rid
});
CreateRRLogEvent(
socket,
"ERROR",
`Error during RR export (selected-customer)`,
withRRRequestXml(error, {
error: error.message,
vendorStatusCode: cls.vendorStatusCode,
code: cls.errorCode,
friendly: cls.friendlyMessage,
stack: error.stack,
jobid: rid
})
);
try {
if (!bodyshop || !job) {
@@ -1477,9 +1626,11 @@ const registerRREvents = ({ socket, redisHelpers }) => {
try {
socket.emit("export-failed", {
vendor: "rr",
jobId: rid,
error: error.message,
...withRRXmlSocketPayload(error, {
vendor: "rr",
jobId: rid,
error: error.message
}),
...cls
});
socket.emit("rr-user-notice", { jobId: rid, ...cls });
@@ -1541,7 +1692,12 @@ const registerRREvents = ({ socket, redisHelpers }) => {
});
if (finalizeResult?.success) {
CreateRRLogEvent(socket, "INFO", `{7} Finalize success; marking exported`, { dmsRoNo, outsdRoNo });
CreateRRLogEvent(
socket,
"INFO",
`{7} Finalize success; marking exported`,
withRRRequestXml(finalizeResult, { dmsRoNo, outsdRoNo })
);
// ✅ Mark exported + success log
await markRRExportSuccess({
@@ -1584,6 +1740,17 @@ const registerRREvents = ({ socket, redisHelpers }) => {
message: vendorMessage
});
CreateRRLogEvent(
socket,
"ERROR",
"Finalize failed",
withRRRequestXml(finalizeResult, {
roStatus: finalizeResult?.roStatus,
statusBlocks: finalizeResult?.statusBlocks,
classification: cls
})
);
await insertRRFailedExportLog({
socket,
jobId: rid,
@@ -1595,23 +1762,30 @@ const registerRREvents = ({ socket, redisHelpers }) => {
});
socket.emit("export-failed", {
vendor: "rr",
jobId: rid,
error: cls?.friendlyMessage || finalizeResult?.error || "RR finalize failed",
...withRRXmlSocketPayload(finalizeResult, {
vendor: "rr",
jobId: rid,
error: cls?.friendlyMessage || finalizeResult?.error || "RR finalize failed"
}),
...cls
});
ack?.({ ok: false, error: cls.friendlyMessage || "RR finalize failed", classification: cls });
}
} catch (error) {
const cls = classifyRRVendorError(error);
CreateRRLogEvent(socket, "ERROR", `Error during RR finalize`, {
error: error.message,
vendorStatusCode: cls.vendorStatusCode,
code: cls.errorCode,
friendly: cls.friendlyMessage,
stack: error.stack,
jobid: rid
});
CreateRRLogEvent(
socket,
"ERROR",
`Error during RR finalize`,
withRRRequestXml(error, {
error: error.message,
vendorStatusCode: cls.vendorStatusCode,
code: cls.errorCode,
friendly: cls.friendlyMessage,
stack: error.stack,
jobid: rid
})
);
try {
if (!bodyshop || !job) {
@@ -1635,7 +1809,17 @@ const registerRREvents = ({ socket, redisHelpers }) => {
});
try {
socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message, ...cls });
socket.emit(
"export-failed",
{
...withRRXmlSocketPayload(error, {
vendor: "rr",
jobId: rid,
error: error.message
}),
...cls
}
);
} catch {
//
}

View File

@@ -1,5 +1,6 @@
const { buildClientAndOpts, rrCombinedSearch } = require("./rr-lookup");
const CreateRRLogEvent = require("./rr-logger-event");
const { withRRRequestXml } = require("./rr-log-xml");
/**
* Pick and normalize VIN from inputs
* @param vin
@@ -168,9 +169,12 @@ const ensureRRServiceVehicle = async (args = {}) => {
if (bodyshop) {
const combinedSearchResponse = await rrCombinedSearch(bodyshop, { kind: "vin", vin: vinStr, maxResults: 50 });
CreateRRLogEvent(socket, "silly", "{SV} Preflight combined search by VIN: raw response", {
response: combinedSearchResponse
});
CreateRRLogEvent(
socket,
"silly",
"{SV} Preflight combined search by VIN: raw response",
withRRRequestXml(combinedSearchResponse, { response: combinedSearchResponse })
);
owners = ownersFromCombined(combinedSearchResponse, vinStr);
}
@@ -194,10 +198,15 @@ const ensureRRServiceVehicle = async (args = {}) => {
}
} catch (e) {
// Preflight shouldn't be fatal; log and continue to insert (idempotency will still be handled)
CreateRRLogEvent(socket, "warn", "{SV} VIN preflight lookup failed; continuing to insert", {
vin: vinStr,
error: e?.message
});
CreateRRLogEvent(
socket,
"warn",
"{SV} VIN preflight lookup failed; continuing to insert",
withRRRequestXml(e, {
vin: vinStr,
error: e?.message
})
);
}
// Vendor says: MODEL DESCRIPTION HAS MAXIMUM LENGTH OF 20
@@ -271,7 +280,7 @@ const ensureRRServiceVehicle = async (args = {}) => {
try {
const res = await client.insertServiceVehicle(insertPayload, insertOpts);
CreateRRLogEvent(socket, "silly", "{SV} insertServiceVehicle: raw response", { res });
CreateRRLogEvent(socket, "silly", "{SV} insertServiceVehicle: raw response", withRRRequestXml(res, { res }));
const data = res?.data ?? {};
const svId = data?.dmsRecKey || data?.svId || undefined;
@@ -309,11 +318,16 @@ const ensureRRServiceVehicle = async (args = {}) => {
};
}
CreateRRLogEvent(socket, "error", "{SV} insertServiceVehicle: failure", {
message: e?.message,
code: e?.code,
status: e?.meta?.status || e?.status
});
CreateRRLogEvent(
socket,
"error",
"{SV} insertServiceVehicle: failure",
withRRRequestXml(e, {
message: e?.message,
code: e?.code,
status: e?.meta?.status || e?.status
})
);
throw e;
}