Compare commits

..

142 Commits

Author SHA1 Message Date
Dave
c3bc29fa9b feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration -Full Flow verified 2025-11-20 14:52:39 -05:00
Dave
34f45379a6 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checking in verified 2025-11-19 11:22:04 -05:00
Dave
e20ef4374c Checkpoint 2025-11-17 17:30:52 -05:00
Dave
43dc760c95 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checking in verified 2025-11-17 15:01:38 -05:00
Dave
cfd5aaff87 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checking in verified 2025-11-17 15:00:36 -05:00
Dave
6162e7f18d feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checking in verified 2025-11-17 14:47:08 -05:00
Dave
cdee754042 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Bump Deps 2025-11-17 12:34:23 -05:00
Dave
a8fb16122a feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Bump Deps 2025-11-14 15:36:40 -05:00
Dave
a88be98d45 Merge remote-tracking branch 'origin/feature/IO-2776-cdk-fortellis' into feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration 2025-11-14 15:27:11 -05:00
Dave
e44bc07ffe Merge remote-tracking branch 'origin/feature/IO-2776-cdk-fortellis' into feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration 2025-11-14 15:22:23 -05:00
Dave
60e8aadd8c feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - shop-info.responsibilitycenters.component.jsx cleanup (remove dead code) 2025-11-14 14:42:50 -05:00
Patrick Fic
daf9f197eb IO-2776 Re-disable make fields. 2025-11-14 11:00:08 -08:00
Patrick Fic
5848daef72 IO-2776 Remove ref file. 2025-11-14 10:58:37 -08:00
Dave
bc77bec610 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-11-14 13:58:20 -05:00
Patrick Fic
c655badae2 Fortellis cleanup. 2025-11-14 10:55:47 -08:00
Patrick Fic
7af70f7512 Additional Fortellis functionality on delete. 2025-11-14 08:36:42 -08:00
Patrick Fic
a8dcc542cc Merge remote-tracking branch 'origin/master-AIO' into feature/IO-2776-cdk-fortellis 2025-11-13 14:27:43 -08:00
Dave
e4c87dd06d feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Enhance logging 2025-11-13 17:08:33 -05:00
Dave
16899007d8 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Enhance logging 2025-11-13 16:53:50 -05:00
Dave
9cb1b25b1d feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Enhance logging 2025-11-13 16:38:00 -05:00
Dave
4c250f6189 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Enhance logging 2025-11-13 15:39:29 -05:00
Dave
9c2c0b665d feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-11-13 14:31:55 -05:00
Dave
09ea6dff2b feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration -Cleaned up DMS key check (consolidated into a helper function), Clean up DMS post form and make it agnostic, same with customer selector. 2025-11-13 11:18:11 -05:00
Dave
577c3bec04 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Expanded Logs / Formatting change 2025-11-12 17:35:07 -05:00
Dave
90f653c0b7 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Expanded Logs / Formatting change 2025-11-12 17:01:54 -05:00
Dave
556cd993b9 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Cashiering Checkpoint 2025-11-12 16:13:23 -05:00
Dave
e3b4620d0c feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Refresh button inside VIN Enforced, add TOo Many OPen RO Handlers 2025-11-12 12:56:34 -05:00
Dave
cbfda822c6 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Bump Deps 2025-11-12 12:28:53 -05:00
Dave
52811f5f45 Merge remote-tracking branch 'origin/release/2025-11-21' into feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration 2025-11-12 12:11:27 -05:00
Dave
29049cf1b0 Merge remote-tracking branch 'origin/release/2025-11-21' into feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration 2025-11-10 20:22:14 -05:00
Dave
b517f3966d Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration 2025-11-10 20:21:11 -05:00
Dave
91f419f4b3 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration Translations / Export Logs 2025-11-10 14:30:11 -05:00
Dave
a5f8fbacc1 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration Clean up posting form 2025-11-10 13:19:54 -05:00
Dave
8bb58df32e feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration Clean up posting form 2025-11-10 13:19:30 -05:00
Dave
1e3b3b853e feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration -Insert Export Log 2025-11-10 12:34:22 -05:00
Dave
02eb212758 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration -Checkpoint 2025-11-10 12:20:30 -05:00
Dave
d45d557a81 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration -Dedupe 2025-11-07 16:09:18 -05:00
Dave
c0157454e1 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - insert service vehical working 2025-11-07 16:02:25 -05:00
Dave
00e6d31a88 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - rr-Utils hardening 2025-11-07 15:57:22 -05:00
Dave
e5ed11287d feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - rr-Utils hardening 2025-11-07 15:43:44 -05:00
Dave
fa250f10a2 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - rr-Utils hardening 2025-11-07 15:28:23 -05:00
Dave
5a8a5bf7ab feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration -VIN Ownership checks 2025-11-07 14:36:57 -05:00
Dave
9ce022b5e8 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Cache advisors for a week / allow them to refresh said cache 2025-11-07 12:26:25 -05:00
Dave
b0b73f1af8 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Cache advisors for a week / allow them to refresh said cache 2025-11-07 11:13:23 -05:00
Dave
a788beaa19 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-11-07 11:01:56 -05:00
Dave
d6b295855d feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Bump Deps 2025-11-06 13:13:09 -05:00
Dave
e36baaa682 Merge remote-tracking branch 'origin/release/2025-11-07' into feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration
# Conflicts:
#	package-lock.json
#	package.json
2025-11-06 13:06:21 -05:00
Dave
35f00df77e Merge remote-tracking branch 'origin/release/2025-11-07' into feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration 2025-11-06 12:52:04 -05:00
Dave
286c49deb1 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-11-05 16:51:11 -05:00
Patrick Fic
a16c680d04 Add department overrides. 2025-11-05 13:23:24 -08:00
Dave
9341806b0f feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-11-05 12:04:14 -05:00
Dave
da28fe8592 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-11-05 11:28:14 -05:00
Dave
f2faa5b686 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-11-05 11:14:54 -05:00
Dave
bedca60744 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-11-05 09:46:22 -05:00
Dave
5344a2031d feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-11-04 16:56:28 -05:00
Dave
c60dfa4319 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-11-04 15:18:08 -05:00
Dave
aa692d4d05 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-11-04 15:18:03 -05:00
Dave
5bbda89fb9 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-11-04 14:19:26 -05:00
Dave
ccf3d7df5b feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-11-04 12:15:27 -05:00
Dave
eeb685802e feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-11-04 11:45:54 -05:00
Dave
ea14606538 Merge remote-tracking branch 'origin/release/2025-11-07' into feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration 2025-11-04 11:19:44 -05:00
Dave
3d24d44274 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-11-04 11:19:20 -05:00
Dave
65e26ed5c9 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Bump packages 2025-10-31 12:09:54 -04:00
Dave
a73617fd3c feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-10-29 12:53:53 -04:00
Dave
32a0e89467 Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration 2025-10-29 10:43:09 -04:00
Dave
e06f0f9918 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint - Remove old attempt at Reynolds Integration in favor of new library. 2025-10-29 10:42:32 -04:00
Dave
319f3220ed feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-10-20 11:23:10 -04:00
Dave
a4da874a1a feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Deps 2025-10-17 17:12:44 -04:00
Dave
2f4c0e329a Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration 2025-10-17 15:00:16 -04:00
Patrick Fic
c6d083ce02 Additional fortellis changes. 2025-10-15 15:20:14 -07:00
Dave
e514cf8d6a feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-10-14 14:55:10 -04:00
Dave
6671db1724 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-10-14 14:26:15 -04:00
Dave
5a9381ebdb feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-10-14 13:23:32 -04:00
Dave
6bab792b5e Merge remote-tracking branch 'origin/release/2025-10-17' into feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration 2025-10-14 10:40:31 -04:00
Dave
64207ef76c feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-10-10 15:47:37 -04:00
Dave
de02b34a63 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-10-08 13:57:34 -04:00
Dave
2ffc4b81f4 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Checkpoint 2025-10-07 16:45:06 -04:00
Patrick Fic
ec30e73b3e WIP Get Makes 2025-10-07 13:23:42 -07:00
Dave
c149d457e7 feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Packages / Styling. 2025-10-07 13:27:30 -04:00
Dave
88c00cf34f Merge remote-tracking branch 'origin/release/2025-10-17' into feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration
# Conflicts:
#	client/package-lock.json
#	client/package.json
#	package-lock.json
#	package.json
2025-10-07 12:25:23 -04:00
Dave
d16c8d5bd5 Merge remote-tracking branch 'origin/feature/IO-2776-cdk-fortellis' into feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration 2025-10-06 14:48:37 -04:00
Patrick Fic
92b05a290e IO-2776 Remove issue checking revoked token causing log issues on wss. 2025-10-06 11:20:52 -07:00
Dave
6421fc8002 Merge remote-tracking branch 'origin/feature/IO-2776-cdk-fortellis' into feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration 2025-10-06 12:56:01 -04:00
Dave
0d9a7dda53 feature/IO-2776-cdk-fortellis - Rename missing WSS loggers 2025-10-06 12:53:21 -04:00
Dave
601eed6db8 feature/IO-2776-cdk-fortellis - Rename missing WSS loggers 2025-10-06 12:29:29 -04:00
Dave
9b708d5e8e Merge remote-tracking branch 'origin/release/2025-10-17' into feature/IO-2776-cdk-fortellis 2025-10-06 12:26:57 -04:00
Dave
6b4bc27205 feature/IO-2776-cdk-fortellis - Remove socket auth , was supposed to be old sockets. 2025-10-06 12:16:31 -04:00
Dave
0eb0e335fe Merge remote-tracking branch 'origin/master-AIO' into feature/IO-2776-cdk-fortellis 2025-10-02 14:13:03 -04:00
Dave
71b08855b8 Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration 2025-10-02 12:16:08 -04:00
Dave
67b1a7f9f4 Merge remote-tracking branch 'origin/release/2025-10-17' into feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration 2025-10-01 19:37:52 -04:00
Dave
24f017bfd2 feature/Reynolds-and-Reynolds-DMS-API-Integration -Expand 2025-10-01 17:11:34 -04:00
Dave
42027f0858 feature/Reynolds-and-Reynolds-DMS-API-Integration -Expand 2025-10-01 15:30:42 -04:00
Dave
3b663d7954 feature/Reynolds-and-Reynolds-DMS-API-Integration -Expand 2025-10-01 15:28:55 -04:00
Dave
e737af2d41 feature/Reynolds-and-Reynolds-DMS-API-Integration -Expand 2025-10-01 14:46:41 -04:00
Dave
10d3b4a485 feature/Reynolds-and-Reynolds-DMS-API-Integration -Expand 2025-10-01 14:39:43 -04:00
Dave
99b79126c3 feature/Reynolds-and-Reynolds-DMS-API-Integration -Expand 2025-10-01 14:38:59 -04:00
Dave
d0eeb7d55d Merge remote-tracking branch 'origin/release/2025-10-17' into feature/Reynolds-and-Reynolds-DMS-API-Integration 2025-10-01 14:23:31 -04:00
Dave
d95c5ce8f9 feature/Reynolds-and-Reynolds-DMS-API-Integration - Use current Token 2025-10-01 14:22:18 -04:00
Dave
b995e1f35d feature/Reynolds-and-Reynolds-DMS-API-Integration - Scaffolding 2025-10-01 14:19:44 -04:00
Dave
3d112ed2cd Merge remote-tracking branch 'origin/master-AIO' into feature/Reynolds-and-Reynolds-DMS-API-Integration 2025-09-30 12:57:23 -04:00
Dave
e978a5a561 Merge remote-tracking branch 'origin/feature/IO-2776-cdk-fortellis' into feature/Reynolds-and-Reynolds-DMS-API-Integration 2025-09-30 12:57:14 -04:00
Patrick Fic
cd1e8b0b15 Add delete DMS wip. 2025-09-25 14:34:31 -07:00
Dave
3d910aa246 Merge branch 'feature/IO-2776-cdk-fortellis' into feature/Reynolds-and-Reynolds-DMS-API-Integration 2025-09-25 12:08:05 -04:00
Dave
f7799ffd03 feature/IO-2776-cdk-fortellis - Cleanup + Auth Socket stuff 2025-09-24 18:12:57 -04:00
Dave
57baa3d9fd Merge remote-tracking branch 'origin/release/2025-09-26' into feature/IO-2776-cdk-fortellis 2025-09-24 17:27:08 -04:00
Dave
c6cd6d0f4e Merge remote-tracking branch 'origin/master-AIO' into feature/Reynolds-and-Reynolds-DMS-API-Integration 2025-09-23 11:00:05 -04:00
Patrick Fic
f796dd0f89 Add DMS error WIP query. 2025-09-22 11:44:46 -07:00
Patrick Fic
517c30787d Improved error logging. 2025-09-18 11:32:48 -07:00
Patrick Fic
c8771275ce WIP and improved error handling 2025-09-15 12:39:35 -07:00
Dave
7c8260685e feature/Reynolds-and-Reynolds-DMS-API-Integration - Add dealer id / merge fortellis 2025-09-12 14:59:42 -04:00
Dave
2fec9fd16e Merge branch 'feature/IO-2776-cdk-fortellis' into feature/Reynolds-and-Reynolds-DMS-API-Integration 2025-09-12 14:58:50 -04:00
Dave
0f1348496c Deps 2025-09-12 12:41:05 -04:00
Patrick Fic
de26979e44 Merge branch 'master-AIO' into feature/IO-2776-cdk-fortellis 2025-09-11 14:40:20 -07:00
Patrick Fic
661678eb1c Additional WIP Fortellis. 2025-09-11 08:45:34 -07:00
Dave
1728982b2b deps 2025-09-08 12:17:17 -04:00
Dave
8aa747edc5 Merge remote-tracking branch 'origin/release/2025-09-12' into feature/IO-2776-cdk-fortellis 2025-09-08 12:16:14 -04:00
Dave Richer
544494ce0d Merged in release/2025-09-12 (pull request #2536)
Release/2025 09 12
2025-09-03 18:54:16 +00:00
Patrick Fic
2e25324ae9 IO-2776 Calculate allocations on load using Fortellis connection. 2025-08-27 16:56:46 -07:00
Patrick Fic
f525ec6fb8 IO-2776 Resolve circular dependancy for legacy WS preventing run time. 2025-08-27 16:52:47 -07:00
Patrick Fic
8296d914c5 IO-2776 Add posting transactions for Fortellis. 2025-08-27 16:37:31 -07:00
Dave Richer
3d691568ff Merged in release/2025-08-29 (pull request #2518)
Release/2025 08 29
2025-08-27 19:27:22 +00:00
Dave
7ab070e2bc feature/IO-2776-cdk-fortellis - Fix linting errors preventing build of front end 2025-08-21 12:23:02 -04:00
Dave
47a974e3cb Merge branch 'feature/IO-3255-simplified-part-management' into feature/IO-2776-cdk-fortellis 2025-08-21 12:04:36 -04:00
Dave
eac81cdffb Merge branch 'feature/IO-3255-simplified-part-management' into feature/IO-2776-cdk-fortellis 2025-08-21 11:45:58 -04:00
Dave
77c3e6f7e7 feature/IO-2776-cdk-fortellis - Update to the latest from simplified Parts Management 2025-08-21 11:28:23 -04:00
Dave
7f07da4360 Merge branch 'feature/IO-3255-simplified-part-management' into feature/IO-2776-cdk-fortellis
# Conflicts:
#	client/package-lock.json
#	client/src/components/dms-cdk-makes/dms-cdk-makes.component.jsx
#	client/src/components/dms-customer-selector/dms-customer-selector.component.jsx
#	client/src/pages/dms/dms.container.jsx
#	package-lock.json
#	package.json
2025-08-21 11:13:27 -04:00
Dave
c47144df72 Merge remote-tracking branch 'origin/master-AIO' into feature/IO-2776-cdk-fortellis
# Conflicts:
#	client/src/components/dms-post-form/dms-post-form.component.jsx
#	package-lock.json
#	package.json
#	server/web-sockets/redisSocketEvents.js
2025-08-21 11:05:03 -04:00
Patrick Fic
163eaac110 Fortellis bug fixes. 2025-05-14 17:48:28 -07:00
Patrick Fic
19ce1c66ad WIP Fortellis. 2025-04-25 11:58:38 -07:00
Patrick Fic
e2b4b408ed Basic posting up to fortellis-select-customer. 2025-04-16 15:55:22 -07:00
Patrick Fic
567171c722 Resolve use socket. 2025-04-14 14:59:36 -07:00
Patrick Fic
03acb78ab2 Merge branch 'master-AIO' into feature/IO-2776-cdk-fortellis 2025-04-14 12:51:43 -07:00
Patrick Fic
e7c4797fef IO-2776 Add additional redis helpers, restructure some fortellis calls. 2025-03-17 10:49:02 -07:00
Patrick Fic
88c35e8c48 Merge branch 'release/2025-03-14' into feature/IO-2776-cdk-fortellis 2025-03-14 09:21:09 -07:00
Patrick Fic
8623172aa1 WIP Fortellis work. 2025-03-13 14:06:07 -07:00
Patrick Fic
6ecc67184d Merge branch 'master-AIO' into feature/IO-2776-cdk-fortellis 2025-03-11 13:08:54 -07:00
Patrick Fic
09973ecb3b Merge branch 'release/2025-02-14' into feature/IO-2776-cdk-fortellis 2025-02-12 08:18:27 -08:00
Patrick Fic
db9e86e4c8 Succesful header creation. 2024-08-15 12:37:05 -07:00
Patrick Fic
85c446bc57 Refactor testing js file and made more modular. 2024-08-15 10:17:11 -07:00
Patrick Fic
5cf6f47bdc Merge remote-tracking branch 'origin/master-AIO' into feature/AIO/IO-2776-cdk-fortellis 2024-08-14 08:18:49 -07:00
Patrick Fic
1db4cbeeb8 Fortellis WIP 2024-05-22 08:18:10 -07:00
Patrick Fic
c88bf4065e Fortellis WIP 2024-05-14 08:15:40 -07:00
146 changed files with 14792 additions and 7023 deletions

346
Fortellis Notes.md Normal file
View File

@@ -0,0 +1,346 @@
Fortellis Notes
Subscription ID
- Appears to give us a list of all dealerships we have access to, and `apiDmsInfo` contains the integrations that are enabled for that dealership.
- Will likely need to filter based on the DMS ID or something?
- Should store the whole subscription object. Contains department information needed in subsequent calls.
Department ID
- May have multiple departments. Appears that financial stuff goes to Accounting, History will go to Service.
- TODO: How do we handle the multiple departments that may come up.
###Internal Questions
* Overview of the redis storing mechanism to cache this data.
*
# GL Wip Posting
## Org Helper Return Data
```json
[
{
"acctgLgnID": "DEVWB-A",
"applCode": "V",
"coID": "77",
"companyName": "TEST SYS C187092 DEVWB",
"lgnDesc": "DEV WRITE BACK VMS",
"logon": "DEVWB-V"
},
{
"acctgLgnID": "DEVWB-A",
"applCode": "F",
"coID": "77",
"companyName": "TEST SYS C187092 DEVWB",
"lgnDesc": "DEV WRITE BACK F&I SALES",
"logon": "DEVWB-FI"
},
{
"acctgLgnID": "DEVWB-A",
"applCode": "CS",
"coID": "77",
"companyName": "TEST SYS C187092 DEVWB",
"lgnDesc": "DEV WRITE BACK SERVICE",
"logon": "DEVWB-S"
},
{
"acctgLgnID": "DEVWB-A",
"applCode": "A",
"coID": "77",
"companyName": "TEST SYS C187092 DEVWB",
"lgnDesc": "DEV WRITE BACK ACCTG",
"logon": "DEVWB-A"
},
{
"acctgLgnID": "DEVWB-A",
"applCode": "SL",
"coID": "77",
"companyName": "TEST SYS C187092 DEVWB",
"lgnDesc": "DEV WRTIE BACK SLS MGMT",
"logon": "DEVWB-SL"
},
{
"acctgLgnID": "DEVWB-A",
"applCode": "O",
"coID": "77",
"companyName": "TEST SYS C187092 DEVWB",
"lgnDesc": "DEV WRITE BACK PARTS",
"logon": "DEVWB-I"
}
]
```
## Journal Helper Return Data
```json
[
{
"companyNo": "77",
"jrnlID": "32",
"jrnlName": "PARTS SALES",
"jrnlType": "S",
"intercoFlag": "0",
"defaultDocType": "4",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "92",
"jrnlName": "YTD ADJUSTMENTS",
"jrnlType": "Y",
"intercoFlag": "0",
"defaultDocType": "3",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "12",
"jrnlName": "FLEET SALES",
"jrnlType": "S",
"intercoFlag": "0",
"defaultDocType": "9",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "57",
"jrnlName": "CASH RECEIPTS (OPEN-ITEM)",
"jrnlType": "R",
"intercoFlag": "0",
"defaultDocType": "1",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "93",
"jrnlName": "SET UP HISTORY",
"jrnlType": "H",
"intercoFlag": "0",
"defaultDocType": "10",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "88",
"jrnlName": "F/S STATISCAL DATA",
"jrnlType": "F",
"intercoFlag": "0",
"defaultDocType": "10",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "58",
"jrnlName": "WARRANTY CREDITS",
"jrnlType": "G",
"intercoFlag": "0",
"defaultDocType": "3",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "FC",
"jrnlName": "FINANCE CHARGE",
"jrnlType": "A",
"intercoFlag": "0",
"defaultDocType": "12",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "94",
"jrnlName": "SET UP SCHEDULES",
"jrnlType": "C",
"intercoFlag": "0",
"defaultDocType": "3",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "95",
"jrnlName": "SET UP GENERAL LEDGER",
"jrnlType": "B",
"intercoFlag": "0",
"defaultDocType": "3",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "20",
"jrnlName": "USED VEHICLE SALES",
"jrnlType": "S",
"intercoFlag": "0",
"defaultDocType": "9",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "60",
"jrnlName": "CASH DISBURSEMENTS",
"jrnlType": "G",
"intercoFlag": "0",
"defaultDocType": "2",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "30",
"jrnlName": "SERVICE SALES",
"jrnlType": "S",
"intercoFlag": "0",
"defaultDocType": "7",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "40",
"jrnlName": "PAYROLL",
"jrnlType": "G",
"intercoFlag": "0",
"defaultDocType": "11",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "15",
"jrnlName": "DEALER TRADES",
"jrnlType": "S",
"intercoFlag": "0",
"defaultDocType": "9",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "70",
"jrnlName": "NEW VEHICLE PURCHASES",
"jrnlType": "G",
"intercoFlag": "0",
"defaultDocType": "8",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "25",
"jrnlName": "USED WHOLESALE",
"jrnlType": "S",
"intercoFlag": "0",
"defaultDocType": "9",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "75",
"jrnlName": "GENERAL PURCHASES",
"jrnlType": "G",
"intercoFlag": "0",
"defaultDocType": "5",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "10",
"jrnlName": "NEW VEHICLE SALES",
"jrnlType": "S",
"intercoFlag": "0",
"defaultDocType": "9",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "80",
"jrnlName": "GENERAL JOURNAL",
"jrnlType": "G",
"intercoFlag": "0",
"defaultDocType": "3",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "11",
"jrnlName": "WORK IN PROGRESS",
"jrnlType": "G",
"intercoFlag": "0",
"defaultDocType": "10",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "56",
"jrnlName": "CASH RECEIPTS (BALANCE FWD)",
"jrnlType": "G",
"intercoFlag": "0",
"defaultDocType": "1",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "81",
"jrnlName": "STANDARD ENTRIES",
"jrnlType": "G",
"intercoFlag": "0",
"defaultDocType": "6",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "51",
"jrnlName": "CASH RECEIPTS JOURNAL - EFT",
"jrnlType": "G",
"intercoFlag": "0",
"defaultDocType": "10",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "61",
"jrnlName": "CASH DISBURSMENTS -EFT",
"jrnlType": "G",
"intercoFlag": "0",
"defaultDocType": "10",
"errCode": "",
"errMsg": ""
},
{
"companyNo": "77",
"jrnlID": "71",
"jrnlName": "USED VEHICLE PURCHASES",
"jrnlType": "G",
"intercoFlag": "0",
"defaultDocType": "8",
"errCode": "",
"errMsg": ""
}
]
```
# Feedback
- Receiving bad request errors, with no details. API errors page doesn't indicate what's wrong for certain types of error codes.
- API Error page works on a several minute delay.

708
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,41 +8,41 @@
"private": true, "private": true,
"proxy": "http://localhost:4000", "proxy": "http://localhost:4000",
"dependencies": { "dependencies": {
"@amplitude/analytics-browser": "^2.25.2", "@amplitude/analytics-browser": "^2.30.1",
"@ant-design/pro-layout": "^7.22.6", "@ant-design/pro-layout": "^7.22.6",
"@apollo/client": "^3.13.9", "@apollo/client": "^3.13.9",
"@emotion/is-prop-valid": "^1.4.0", "@emotion/is-prop-valid": "^1.4.0",
"@fingerprintjs/fingerprintjs": "^4.6.1", "@fingerprintjs/fingerprintjs": "^4.6.1",
"@firebase/analytics": "^0.10.17", "@firebase/analytics": "^0.10.19",
"@firebase/app": "^0.14.3", "@firebase/app": "^0.14.6",
"@firebase/auth": "^1.10.8", "@firebase/auth": "^1.11.1",
"@firebase/firestore": "^4.9.2", "@firebase/firestore": "^4.9.2",
"@firebase/messaging": "^0.12.22", "@firebase/messaging": "^0.12.22",
"@jsreport/browser-client": "^3.1.0", "@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.9.0", "@reduxjs/toolkit": "^2.10.1",
"@sentry/cli": "^2.56.0", "@sentry/cli": "^2.58.2",
"@sentry/react": "^9.43.0", "@sentry/react": "^9.43.0",
"@sentry/vite-plugin": "^4.3.0", "@sentry/vite-plugin": "^4.6.0",
"@splitsoftware/splitio-react": "^2.5.0", "@splitsoftware/splitio-react": "^2.6.0",
"@tanem/react-nprogress": "^5.0.53", "@tanem/react-nprogress": "^5.0.56",
"antd": "^5.27.4", "antd": "^5.28.1",
"apollo-link-logger": "^2.0.1", "apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^4.4.0", "apollo-link-sentry": "^4.4.0",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.12.2", "axios": "^1.13.2",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"css-box-model": "^1.2.1", "css-box-model": "^1.2.1",
"dayjs": "^1.11.18", "dayjs": "^1.11.19",
"dayjs-business-days2": "^1.3.0", "dayjs-business-days2": "^1.3.1",
"dinero.js": "^1.9.1", "dinero.js": "^1.9.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"env-cmd": "^10.1.0", "env-cmd": "^10.1.0",
"exifr": "^7.1.3", "exifr": "^7.1.3",
"graphql": "^16.11.0", "graphql": "^16.12.0",
"i18next": "^25.5.3", "i18next": "^25.6.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"immutability-helper": "^3.1.1", "immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.23", "libphonenumber-js": "^1.12.26",
"lightningcss": "^1.30.2", "lightningcss": "^1.30.2",
"logrocket": "^9.0.2", "logrocket": "^9.0.2",
"markerjs2": "^2.32.7", "markerjs2": "^2.32.7",
@@ -50,7 +50,7 @@
"normalize-url": "^8.1.0", "normalize-url": "^8.1.0",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"phone": "^3.1.67", "phone": "^3.1.67",
"posthog-js": "^1.271.0", "posthog-js": "^1.294.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"query-string": "^9.3.1", "query-string": "^9.3.1",
"raf-schd": "^4.0.3", "raf-schd": "^4.0.3",
@@ -68,7 +68,7 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-number-format": "^5.4.3", "react-number-format": "^5.4.3",
"react-popopo": "^2.1.9", "react-popopo": "^2.1.9",
"react-product-fruits": "^2.2.61", "react-product-fruits": "^2.2.62",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-resizable": "^3.0.5", "react-resizable": "^3.0.5",
"react-router-dom": "^6.30.0", "react-router-dom": "^6.30.0",
@@ -78,10 +78,10 @@
"redux": "^5.0.1", "redux": "^5.0.1",
"redux-actions": "^3.0.3", "redux-actions": "^3.0.3",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"redux-saga": "^1.3.0", "redux-saga": "^1.4.2",
"redux-state-sync": "^3.1.4", "redux-state-sync": "^3.1.4",
"reselect": "^5.1.1", "reselect": "^5.1.1",
"sass": "^1.93.2", "sass": "^1.94.0",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"styled-components": "^6.1.19", "styled-components": "^6.1.19",
"subscriptions-transport-ws": "^0.11.0", "subscriptions-transport-ws": "^0.11.0",
@@ -135,35 +135,35 @@
"devDependencies": { "devDependencies": {
"@ant-design/icons": "^6.1.0", "@ant-design/icons": "^6.1.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.27.1", "@babel/preset-react": "^7.28.5",
"@dotenvx/dotenvx": "^1.51.0", "@dotenvx/dotenvx": "^1.51.1",
"@emotion/babel-plugin": "^11.13.5", "@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@eslint/js": "^9.37.0", "@eslint/js": "^9.39.1",
"@playwright/test": "^1.56.0", "@playwright/test": "^1.56.1",
"@sentry/webpack-plugin": "^4.3.0", "@sentry/webpack-plugin": "^4.6.0",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"browserslist": "^4.26.3", "browserslist": "^4.28.0",
"browserslist-to-esbuild": "^2.1.1", "browserslist-to-esbuild": "^2.1.1",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"eslint": "^9.37.0", "eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0", "globals": "^15.15.0",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"memfs": "^4.48.1", "memfs": "^4.51.0",
"os-browserify": "^0.3.0", "os-browserify": "^0.3.0",
"playwright": "^1.56.0", "playwright": "^1.56.1",
"react-error-overlay": "^6.1.0", "react-error-overlay": "^6.1.0",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.3", "source-map-explorer": "^2.5.3",
"vite": "^7.1.9", "vite": "^7.2.2",
"vite-plugin-babel": "^1.3.2", "vite-plugin-babel": "^1.3.2",
"vite-plugin-eslint": "^1.8.1", "vite-plugin-eslint": "^1.8.1",
"vite-plugin-node-polyfills": "^0.24.0", "vite-plugin-node-polyfills": "^0.24.0",
"vite-plugin-pwa": "^1.0.3", "vite-plugin-pwa": "^1.1.0",
"vite-plugin-style-import": "^2.0.0", "vite-plugin-style-import": "^2.0.0",
"vitest": "^3.2.4", "vitest": "^3.2.4",
"workbox-window": "^7.3.0" "workbox-window": "^7.3.0"

View File

@@ -138,7 +138,7 @@ export function App({
); );
} }
if (!isPartsEntry && currentEula && !currentUser.eulaIsAccepted) { if (currentEula && !currentUser.eulaIsAccepted) {
return <Eula />; return <Eula />;
} }

View File

@@ -1,3 +1,5 @@
@use "react-big-calendar/lib/sass/styles" as rbc;
:root { :root {
--table-stripe-bg: #f4f4f4; /* Light mode table stripe */ --table-stripe-bg: #f4f4f4; /* Light mode table stripe */
--menu-divider-color: #74695c; /* Light mode menu divider */ --menu-divider-color: #74695c; /* Light mode menu divider */
@@ -211,9 +213,6 @@
--svg-background: #FFF; /* Dark mode SVG background */ --svg-background: #FFF; /* Dark mode SVG background */
} }
// Global Styles
@import "react-big-calendar/lib/sass/styles";
.ant-menu-item-divider { .ant-menu-item-divider {
border-bottom: 1px solid var(--menu-divider-color) !important; border-bottom: 1px solid var(--menu-divider-color) !important;
} }
@@ -428,4 +427,4 @@
//.rbc-time-header-gutter { //.rbc-time-header-gutter {
// padding: 0; // padding: 0;
//} //}

View File

@@ -180,7 +180,7 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
<Card <Card
extra={ extra={
<Space wrap> <Space wrap>
{!bodyshop.cdk_dealerid && !bodyshop.pbs_serialnumber && ( {!bodyshop.cdk_dealerid && !bodyshop.pbs_serialnumber && !bodyshop.rr_dealerid && (
<> <>
<JobMarkSelectedExported <JobMarkSelectedExported
jobIds={selectedJobs} jobIds={selectedJobs}
@@ -198,7 +198,7 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
/> />
</> </>
)} )}
{bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && <QboAuthorizeComponent />} {bodyshop.accountingconfig?.qbo && <QboAuthorizeComponent />}
<Input.Search <Input.Search
value={state.search} value={state.search}
onChange={handleSearch} onChange={handleSearch}

View File

@@ -26,7 +26,6 @@ import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility";
import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility"; import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility";
import { handleUpload } from "../documents-upload/documents-upload.utility"; import { handleUpload } from "../documents-upload/documents-upload.utility";
import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility"; import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility";
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
billEnterModal: selectBillEnterModal, billEnterModal: selectBillEnterModal,
@@ -451,9 +450,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
setEnterAgain(false); setEnterAgain(false);
}} }}
> >
<RbacWrapper action="bills:enter"> <BillFormContainer form={form} disableInvNumber={billEnterModal.context.disableInvNumber} />
<BillFormContainer form={form} disableInvNumber={billEnterModal.context.disableInvNumber} />
</RbacWrapper>
</Form> </Form>
</Modal> </Modal>
); );

View File

@@ -7,6 +7,7 @@ import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import CurrencyInput from "../form-items-formatted/currency-form-item.component"; import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import CiecaSelect from "../../utils/Ciecaselect"; import CiecaSelect from "../../utils/Ciecaselect";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
@@ -44,7 +45,7 @@ export function BillFormItemsExtendedFormItem({
quantity: record.part_qty || 1, quantity: record.part_qty || 1,
actual_price: record.act_price, actual_price: record.act_price,
cost_center: record.part_type cost_center: record.part_type
? bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid ? bodyshopHasDmsKey(bodyshop)
? record.part_type ? record.part_type
: responsibilityCenters.defaults && (responsibilityCenters.defaults.costs[record.part_type] || null) : responsibilityCenters.defaults && (responsibilityCenters.defaults.costs[record.part_type] || null)
: null : null
@@ -100,7 +101,7 @@ export function BillFormItemsExtendedFormItem({
</Form.Item> </Form.Item>
<Form.Item label={t("billlines.fields.cost_center")} name={["billlineskeys", record.id, "cost_center"]}> <Form.Item label={t("billlines.fields.cost_center")} name={["billlineskeys", record.id, "cost_center"]}>
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled}> <Select showSearch style={{ minWidth: "3rem" }} disabled={disabled}>
{bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber {bodyshopHasDmsKey(bodyshop)
? CiecaSelect(true, false) ? CiecaSelect(true, false)
: responsibilityCenters.costs.map((item) => <Select.Option key={item.name}>{item.name}</Select.Option>)} : responsibilityCenters.costs.map((item) => <Select.Option key={item.name}>{item.name}</Select.Option>)}
</Select> </Select>

View File

@@ -22,6 +22,7 @@ import VendorSearchSelect from "../vendor-search-select/vendor-search-select.com
import BillFormLines from "./bill-form.lines.component"; import BillFormLines from "./bill-form.lines.component";
import { CalculateBillTotal } from "./bill-form.totals.utility"; import { CalculateBillTotal } from "./bill-form.totals.utility";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx"; import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
@@ -354,7 +355,7 @@ export function BillFormComponent({
<Form.Item span={3} label={t("bills.fields.local_tax_rate")} name="local_tax_rate"> <Form.Item span={3} label={t("bills.fields.local_tax_rate")} name="local_tax_rate">
<CurrencyInput min={0} /> <CurrencyInput min={0} />
</Form.Item> </Form.Item>
{bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid ? ( {bodyshopHasDmsKey(bodyshop) ? (
<Form.Item span={2} label={t("bills.labels.federal_tax_exempt")} name="federal_tax_exempt"> <Form.Item span={2} label={t("bills.labels.federal_tax_exempt")} name="federal_tax_exempt">
<Switch onChange={handleFederalTaxExemptSwitchToggle} /> <Switch onChange={handleFederalTaxExemptSwitchToggle} />
</Form.Item> </Form.Item>

View File

@@ -10,6 +10,7 @@ import InstanceRenderManager from "../../utils/instanceRenderMgr";
import BillLineSearchSelect from "../bill-line-search-select/bill-line-search-select.component"; import BillLineSearchSelect from "../bill-line-search-select/bill-line-search-select.component";
import BilllineAddInventory from "../billline-add-inventory/billline-add-inventory.component"; import BilllineAddInventory from "../billline-add-inventory/billline-add-inventory.component";
import CurrencyInput from "../form-items-formatted/currency-form-item.component"; import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser //currentUser: selectCurrentUser
@@ -90,7 +91,7 @@ export function BillEnterModalLinesComponent({
actual_price: opt.cost, actual_price: opt.cost,
original_actual_price: opt.cost, original_actual_price: opt.cost,
cost_center: opt.part_type cost_center: opt.part_type
? bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid ? bodyshopHasDmsKey(bodyshop)
? opt.part_type !== "PAE" ? opt.part_type !== "PAE"
? opt.part_type ? opt.part_type
: null : null
@@ -322,7 +323,7 @@ export function BillEnterModalLinesComponent({
}, },
formInput: () => ( formInput: () => (
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled}> <Select showSearch style={{ minWidth: "3rem" }} disabled={disabled}>
{bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber {bodyshopHasDmsKey(bodyshop)
? CiecaSelect(true, false) ? CiecaSelect(true, false)
: responsibilityCenters.costs.map((item) => <Select.Option key={item.name}>{item.name}</Select.Option>)} : responsibilityCenters.costs.map((item) => <Select.Option key={item.name}>{item.name}</Select.Option>)}
</Select> </Select>

View File

@@ -2,6 +2,7 @@ import { useApolloClient } from "@apollo/client";
import { getToken } from "@firebase/messaging"; import { getToken } from "@firebase/messaging";
import axios from "axios"; import axios from "axios";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { messaging, requestForToken } from "../../firebase/firebase.utils"; import { messaging, requestForToken } from "../../firebase/firebase.utils";
import ChatPopupComponent from "../chat-popup/chat-popup.component"; import ChatPopupComponent from "../chat-popup/chat-popup.component";
import "./chat-affix.styles.scss"; import "./chat-affix.styles.scss";
@@ -9,14 +10,14 @@ import { registerMessagingHandlers, unregisterMessagingHandlers } from "./regist
import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import { useSocket } from "../../contexts/SocketIO/useSocket.js";
export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) { export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) {
const { t } = useTranslation();
const client = useApolloClient(); const client = useApolloClient();
const { socket } = useSocket(); const { socket } = useSocket();
// 1) FCM subscription (independent of socket handler registration)
useEffect(() => { useEffect(() => {
if (!bodyshop?.messagingservicesid) return; if (!bodyshop?.messagingservicesid) return;
async function subscribeToTopicForFCMNotification() { async function SubscribeToTopicForFCMNotification() {
try { try {
await requestForToken(); await requestForToken();
await axios.post("/notifications/subscribe", { await axios.post("/notifications/subscribe", {
@@ -31,35 +32,17 @@ export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) {
} }
} }
subscribeToTopicForFCMNotification(); SubscribeToTopicForFCMNotification();
}, [bodyshop?.messagingservicesid, bodyshop?.imexshopid]);
// 2) Register socket handlers as soon as socket is connected (regardless of chatVisible) // Register WebSocket handlers
useEffect(() => { if (socket?.connected) {
if (!socket) return; registerMessagingHandlers({ socket, client, currentUser, bodyshop, t });
if (!bodyshop?.messagingservicesid) return;
if (!bodyshop?.id) return;
// If socket isn't connected yet, ensure no stale handlers remain. return () => {
if (!socket.connected) { unregisterMessagingHandlers({ socket });
unregisterMessagingHandlers({ socket }); };
return;
} }
}, [bodyshop, socket, t, client]);
// Prevent duplicate listeners if this effect runs more than once.
unregisterMessagingHandlers({ socket });
registerMessagingHandlers({
socket,
client,
currentUser,
bodyshop
});
return () => {
unregisterMessagingHandlers({ socket });
};
}, [socket, socket?.connected, bodyshop?.id, bodyshop?.messagingservicesid, client, currentUser?.email]);
if (!bodyshop?.messagingservicesid) return <></>; if (!bodyshop?.messagingservicesid) return <></>;

View File

@@ -13,241 +13,68 @@ const logLocal = (message, ...args) => {
} }
}; };
const safeIsoNow = () => new Date().toISOString(); // Utility function to enrich conversation data
const isSystemMsid = (msid) => typeof msid === "string" && msid.startsWith("SYS_"); const enrichConversation = (conversation, isOutbound) => ({
const normalizeConversationForList = (raw, { isoutbound, isSystem } = {}) => {
const c = raw || {};
const id = c.id;
return {
__typename: "conversations",
id,
phone_num: c.phone_num ?? null,
updated_at: c.updated_at ?? safeIsoNow(),
unreadcnt: typeof c.unreadcnt === "number" ? c.unreadcnt : 0,
archived: c.archived ?? false,
label: c.label ?? null,
job_conversations: Array.isArray(c.job_conversations)
? c.job_conversations.map((jc) => {
const job = jc?.job || {};
const jobId = jc?.jobid ?? job?.id ?? null;
return {
__typename: "job_conversations",
jobid: jobId,
conversationid: jc?.conversationid ?? id ?? null,
job: {
__typename: "jobs",
id: jobId,
ro_number: job?.ro_number ?? null,
ownr_fn: job?.ownr_fn ?? null,
ownr_ln: job?.ownr_ln ?? null,
ownr_co_nm: job?.ownr_co_nm ?? null
}
};
})
: [],
messages_aggregate: c.messages_aggregate || {
__typename: "messages_aggregate",
aggregate: {
__typename: "messages_aggregate_fields",
count: isoutbound || isSystem ? 0 : 1
}
}
};
};
const CONVERSATION_LIST_ITEM_FRAGMENT = gql`
fragment _ConversationListItem on conversations {
id
phone_num
updated_at
unreadcnt
archived
label
job_conversations {
jobid
conversationid
job {
id
ro_number
ownr_fn
ownr_ln
ownr_co_nm
}
}
messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false }, is_system: { _eq: false } }) {
aggregate {
count
}
}
}
`;
const normalizeMessageForCache = (raw, fallbackConversationId) => {
const m = raw || {};
return {
__typename: "messages",
id: m.id,
conversationid: m.conversationid ?? m.conversation?.id ?? fallbackConversationId,
status: m.status ?? null,
text: m.text ?? "",
is_system: typeof m.is_system === "boolean" ? m.is_system : false,
isoutbound: typeof m.isoutbound === "boolean" ? m.isoutbound : false,
image: typeof m.image === "boolean" ? m.image : false,
image_path: m.image_path ?? null,
userid: m.userid ?? null,
created_at: m.created_at ?? safeIsoNow(),
read: typeof m.read === "boolean" ? m.read : false
};
};
const isConversationDetailsCached = (client, conversationId) => {
try {
return !!client.cache.readQuery({
query: GET_CONVERSATION_DETAILS,
variables: { conversationId }
})?.conversations_by_pk;
} catch {
return false;
}
};
const conversationDetailsCached = (client, conversationId) => {
try {
const res = client.cache.readQuery({
query: GET_CONVERSATION_DETAILS,
variables: { conversationId }
});
return !!res?.conversations_by_pk;
} catch {
return false;
}
};
const messageEntityCached = (client, messageId) => {
const cacheId = client.cache.identify({ __typename: "messages", id: messageId });
if (!cacheId) return false;
try {
client.cache.readFragment({
id: cacheId,
fragment: gql`
fragment _MsgExists on messages {
id
}
`
});
return true;
} catch {
return false;
}
};
const enrichConversation = (conversation, { isoutbound, isSystem }) => ({
...conversation, ...conversation,
updated_at: conversation.updated_at || safeIsoNow(), updated_at: conversation.updated_at || new Date().toISOString(),
unreadcnt: typeof conversation.unreadcnt === "number" ? conversation.unreadcnt : 0, unreadcnt: conversation.unreadcnt || 0,
archived: conversation.archived ?? false, archived: conversation.archived || false,
label: conversation.label ?? null, label: conversation.label || null,
job_conversations: conversation.job_conversations || [], job_conversations: conversation.job_conversations || [],
messages_aggregate: conversation.messages_aggregate || { messages_aggregate: conversation.messages_aggregate || {
__typename: "messages_aggregate", __typename: "messages_aggregate",
aggregate: { aggregate: {
__typename: "messages_aggregate_fields", __typename: "messages_aggregate_fields",
count: isoutbound || isSystem ? 0 : 1 count: isOutbound ? 0 : 1
} }
}, },
__typename: "conversations" __typename: "conversations"
}); });
const upsertConversationIntoOffsetZeroList = (client, conversationObj, { isoutbound, isSystem } = {}) => { // Can be uncommonted to test the playback of the notification sound
const normalized = normalizeConversationForList(conversationObj, { isoutbound, isSystem }); // window.testTone = () => {
if (!normalized?.id) return; // const notificationSound = new Audio(newMessageSound);
// notificationSound.play().catch((error) => {
const convCacheId = client.cache.identify(normalized); // console.error("Error playing notification sound:", error);
if (!convCacheId) return; // });
// };
client.cache.writeFragment({
id: convCacheId,
fragment: CONVERSATION_LIST_ITEM_FRAGMENT,
data: normalized
});
client.cache.modify({
id: "ROOT_QUERY",
fields: {
conversations(existing = [], { args, readField }) {
if (!args || args.offset !== 0) return existing;
const archivedEq = args?.where?.archived?._eq;
if (archivedEq === true) return existing;
const without = existing.filter((c) => readField("id", c) !== normalized.id);
return [{ __ref: convCacheId }, ...without];
}
}
});
};
export const registerMessagingHandlers = ({ socket, client, currentUser, bodyshop }) => { export const registerMessagingHandlers = ({ socket, client, currentUser, bodyshop }) => {
if (!(socket && client)) return; if (!(socket && client)) return;
// Coalesce unread refetches (avoid spamming during bursts)
let unreadRefetchInFlight = null;
const refetchUnreadCount = () => {
if (unreadRefetchInFlight) return;
unreadRefetchInFlight = client
.refetchQueries({
include: ["UNREAD_CONVERSATION_COUNT"]
})
.catch(() => {
// best-effort
})
.finally(() => {
unreadRefetchInFlight = null;
});
};
const handleNewMessageSummary = async (message) => { const handleNewMessageSummary = async (message) => {
const { conversationId, newConversation, existingConversation, isoutbound, msid, updated_at } = message; const { conversationId, newConversation, existingConversation, isoutbound } = message;
const isSystem = isSystemMsid(msid);
const isNewMessageSoundEnabled = (clientInstance) => { // True only when DB value is strictly true; falls back to true on cache miss
const isNewMessageSoundEnabled = (client) => {
try { try {
const email = currentUser?.email; const email = currentUser?.email;
if (!email) return true; if (!email) return true; // default allow if we can't resolve user
const res = clientInstance.readQuery({ const res = client.readQuery({
query: QUERY_ACTIVE_ASSOCIATION_SOUND, query: QUERY_ACTIVE_ASSOCIATION_SOUND,
variables: { email } variables: { email }
}); });
const flag = res?.associations?.[0]?.new_message_sound; const flag = res?.associations?.[0]?.new_message_sound;
return flag === true; return flag === true; // strictly true => enabled
} catch { } catch {
// If the query hasn't been seeded in cache yet, default ON
return true; return true;
} }
}; };
logLocal("handleNewMessageSummary - Start", { message, isNew: !existingConversation }); logLocal("handleNewMessageSummary - Start", { message, isNew: !existingConversation });
const queryVariables = { offset: 0 };
if (!isoutbound) { if (!isoutbound) {
// Play notification sound for new inbound message (scoped to bodyshop)
if (isLeaderTab(bodyshop.id) && isNewMessageSoundEnabled(client)) { if (isLeaderTab(bodyshop.id) && isNewMessageSoundEnabled(client)) {
playNewMessageSound(bodyshop.id); playNewMessageSound(bodyshop.id);
} }
// Real-time badge update for affix (best-effort, coalesced)
if (!isSystem) {
refetchUnreadCount();
}
} }
if (!existingConversation && conversationId) { if (!existingConversation && conversationId) {
// Attempt to read from the cache to determine if this is actually a new conversation
try { try {
const cachedConversation = client.cache.readFragment({ const cachedConversation = client.cache.readFragment({
id: client.cache.identify({ __typename: "conversations", id: conversationId }), id: client.cache.identify({ __typename: "conversations", id: conversationId }),
@@ -259,54 +86,75 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
}); });
if (cachedConversation) { if (cachedConversation) {
logLocal("handleNewMessageSummary - Existing Conversation inferred from cache", { conversationId }); logLocal("handleNewMessageSummary - Existing Conversation inferred from cache", {
return handleNewMessageSummary({ ...message, existingConversation: true }); conversationId
});
return handleNewMessageSummary({
...message,
existingConversation: true
});
} }
} catch { } catch {
// Cache miss is normal logLocal("handleNewMessageSummary - Cache miss", { conversationId });
} }
} }
// Handle new conversation
if (!existingConversation && newConversation?.phone_num) { if (!existingConversation && newConversation?.phone_num) {
logLocal("handleNewMessageSummary - New Conversation", newConversation);
try { try {
upsertConversationIntoOffsetZeroList(client, newConversation, { isoutbound, isSystem }); const queryResults = client.cache.readQuery({
query: CONVERSATION_LIST_QUERY,
variables: queryVariables
});
const existingConversations = queryResults?.conversations || [];
const enrichedConversation = enrichConversation(newConversation, isoutbound);
if (!existingConversations.some((conv) => conv.id === enrichedConversation.id)) {
client.cache.modify({
id: "ROOT_QUERY",
fields: {
conversations(existingConversations = []) {
return [enrichedConversation, ...existingConversations];
}
}
});
}
} catch (error) { } catch (error) {
console.error("Error updating cache for new conversation:", error); console.error("Error updating cache for new conversation:", error);
} }
return; return;
} }
if (existingConversation && conversationId) { // Handle existing conversation
if (existingConversation) {
try { try {
client.cache.modify({ client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }), id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: { fields: {
updated_at: () => updated_at || safeIsoNow(), updated_at: () => new Date().toISOString(),
archived: () => false, archived: () => false,
messages_aggregate(cached = { aggregate: { count: 0 } }) {
messages_aggregate(cached = null) { const currentCount = cached.aggregate?.count || 0;
if (isoutbound || isSystem) return cached; if (!isoutbound) {
return {
const currentCount = cached?.aggregate?.count ?? 0; __typename: "messages_aggregate",
return { aggregate: {
__typename: "messages_aggregate", __typename: "messages_aggregate_fields",
aggregate: { count: currentCount + 1
__typename: "messages_aggregate_fields", }
count: currentCount + 1 };
} }
}; return cached;
},
unreadcnt(cached) {
if (isoutbound || isSystem) return cached;
const n = typeof cached === "number" ? cached : 0;
return n + 1;
} }
} }
}); });
} catch (error) { } catch (error) {
console.error("Error updating cache for existing conversation:", error); console.error("Error updating cache for existing conversation:", error);
} }
return; return;
} }
@@ -318,78 +166,88 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
logLocal("handleNewMessageDetailed - Start", message); logLocal("handleNewMessageDetailed - Start", message);
if (!conversationId || !isConversationDetailsCached(client, conversationId)) return;
try { try {
const normalized = normalizeMessageForCache(newMessage, conversationId); // Check if the conversation exists in the cache
const queryResults = client.cache.readQuery({
query: GET_CONVERSATION_DETAILS,
variables: { conversationId }
});
const messageCacheId = client.cache.identify(normalized); if (!queryResults?.conversations_by_pk) {
if (!messageCacheId) { console.warn("Conversation not found in cache:", { conversationId });
console.warn("handleNewMessageDetailed - Could not identify message for cache", {
conversationId,
newMessageId: newMessage?.id
});
return; return;
} }
client.cache.writeFragment({ // Append the new message to the conversation's message list using cache.modify
id: messageCacheId,
fragment: gql`
fragment _IncomingMessage on messages {
id
conversationid
status
text
is_system
isoutbound
image
image_path
userid
created_at
read
}
`,
data: normalized
});
client.cache.modify({ client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }), id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: { fields: {
messages(existing = [], { readField }) { messages(existingMessages = []) {
const already = existing.some((ref) => readField("id", ref) === normalized.id); return [...existingMessages, newMessage];
if (already) return existing;
return [...existing, { __ref: messageCacheId }];
},
updated_at() {
return normalized.created_at || safeIsoNow();
} }
} }
}); });
logLocal("handleNewMessageDetailed - Message appended successfully", { conversationId }); logLocal("handleNewMessageDetailed - Message appended successfully", {
conversationId,
newMessage
});
} catch (error) { } catch (error) {
console.error("Error updating conversation messages in cache:", error); console.error("Error updating conversation messages in cache:", error);
} }
}; };
const handleMessageChanged = (message) => { const handleMessageChanged = (message) => {
if (!message?.id) return; if (!message) {
logLocal("handleMessageChanged - No message provided", message);
return;
}
logLocal("handleMessageChanged - Start", message); logLocal("handleMessageChanged - Start", message);
if (!messageEntityCached(client, message.id)) return;
try { try {
const msgCacheId = client.cache.identify({ __typename: "messages", id: message.id });
client.cache.modify({ client.cache.modify({
id: msgCacheId, id: client.cache.identify({ __typename: "conversations", id: message.conversationid }),
fields: { fields: {
status(existing) { messages(existingMessages = [], { readField }) {
return message.type === "status-changed" ? (message.status ?? existing) : existing; return existingMessages.map((messageRef) => {
}, // Check if this is the message to update
text(existing) { if (readField("id", messageRef) === message.id) {
return message.type === "text-updated" ? (message.text ?? existing) : existing; const currentStatus = readField("status", messageRef);
// Handle known types of message changes
switch (message.type) {
case "status-changed":
// Prevent overwriting if the current status is already "delivered"
if (currentStatus === "delivered") {
logLocal("handleMessageChanged - Status already delivered, skipping update", {
messageId: message.id
});
return messageRef;
}
// Update the status field
return {
...messageRef,
status: message.status
};
case "text-updated":
// Handle changes to the message text
return {
...messageRef,
text: message.text
};
default:
// Log a warning for unhandled message types
logLocal("handleMessageChanged - Unhandled message type", { type: message.type });
return messageRef;
}
}
return messageRef;
});
} }
} }
}); });
@@ -404,140 +262,149 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
}; };
const handleConversationChanged = async (data) => { const handleConversationChanged = async (data) => {
if (!data?.conversationId) return; if (!data) {
logLocal("handleConversationChanged - No data provided", data);
const { return;
conversationId, }
type,
job_conversations,
messageIds,
messageIdsMarkedRead,
lastUnreadMessageId,
unreadCount,
...fields
} = data;
const { conversationId, type, job_conversations, messageIds, ...fields } = data;
logLocal("handleConversationChanged - Start", data); logLocal("handleConversationChanged - Start", data);
const updatedAt = safeIsoNow(); const updatedAt = new Date().toISOString();
const detailsCached = conversationDetailsCached(client, conversationId);
const updateConversationList = (newConversation) => {
try {
const existingList = client.cache.readQuery({
query: CONVERSATION_LIST_QUERY,
variables: { offset: 0 }
});
const updatedList = existingList?.conversations
? [newConversation, ...existingList.conversations.filter((conv) => conv.id !== newConversation.id)]
: [newConversation]; // Prevent duplicates
client.cache.writeQuery({
query: CONVERSATION_LIST_QUERY,
variables: { offset: 0 },
data: {
conversations: updatedList
}
});
logLocal("handleConversationChanged - Conversation list updated successfully", newConversation);
} catch (error) {
console.error("Error updating conversation list in the cache:", error);
}
};
// Handle specific types
try { try {
switch (type) { switch (type) {
case "conversation-marked-read": { case "conversation-marked-read":
refetchUnreadCount(); if (conversationId && messageIds?.length > 0) {
client.cache.modify({
if (detailsCached && Array.isArray(messageIds)) { id: client.cache.identify({ __typename: "conversations", id: conversationId }),
messageIds.forEach((id) => { fields: {
if (!messageEntityCached(client, id)) return; messages(existingMessages = [], { readField }) {
client.cache.modify({ return existingMessages.map((message) => {
id: client.cache.identify({ __typename: "messages", id }), if (messageIds.includes(readField("id", message))) {
fields: { read: () => true } return { ...message, read: true };
}); }
return message;
});
},
messages_aggregate: () => ({
__typename: "messages_aggregate",
aggregate: { __typename: "messages_aggregate_fields", count: 0 }
})
}
}); });
} }
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
messages_aggregate: () => ({
__typename: "messages_aggregate",
aggregate: { __typename: "messages_aggregate_fields", count: 0 }
}),
unreadcnt: () => 0,
updated_at: () => updatedAt
}
});
break; break;
}
case "conversation-marked-unread": {
refetchUnreadCount();
const safeUnreadCount = typeof unreadCount === "number" ? unreadCount : 1;
const idsMarkedRead = Array.isArray(messageIdsMarkedRead) ? messageIdsMarkedRead : [];
if (detailsCached) {
idsMarkedRead.forEach((id) => {
if (!messageEntityCached(client, id)) return;
client.cache.modify({
id: client.cache.identify({ __typename: "messages", id }),
fields: { read: () => true }
});
});
if (lastUnreadMessageId && messageEntityCached(client, lastUnreadMessageId)) {
client.cache.modify({
id: client.cache.identify({ __typename: "messages", id: lastUnreadMessageId }),
fields: { read: () => false }
});
}
}
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
updated_at: () => updatedAt,
messages_aggregate: () => ({
__typename: "messages_aggregate",
aggregate: {
__typename: "messages_aggregate_fields",
count: safeUnreadCount
}
}),
unreadcnt: () => safeUnreadCount
}
});
case "conversation-created":
updateConversationList({ ...fields, job_conversations, updated_at: updatedAt });
break; break;
}
case "conversation-created": {
// New conversation likely implies new unread inbound message(s)
refetchUnreadCount();
const conv = enrichConversation(
{ id: conversationId, job_conversations, ...fields, updated_at: updatedAt },
{ isoutbound: false, isSystem: false }
);
upsertConversationIntoOffsetZeroList(client, conv);
break;
}
case "conversation-unarchived": case "conversation-unarchived":
case "conversation-archived": { case "conversation-archived":
// Keep unread badge correct even if archiving affects counts try {
refetchUnreadCount(); const listQueryVariables = { offset: 0 };
const detailsQueryVariables = { conversationId };
await client.refetchQueries({ // Check if conversation details exist in the cache
include: [CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS] const detailsExist = !!client.cache.readQuery({
}); query: GET_CONVERSATION_DETAILS,
variables: detailsQueryVariables
});
// Refetch conversation list
await client.refetchQueries({
include: [CONVERSATION_LIST_QUERY, ...(detailsExist ? [GET_CONVERSATION_DETAILS] : [])],
variables: [
{ query: CONVERSATION_LIST_QUERY, variables: listQueryVariables },
...(detailsExist
? [
{
query: GET_CONVERSATION_DETAILS,
variables: detailsQueryVariables
}
]
: [])
]
});
logLocal("handleConversationChanged - Refetched queries after state change", {
conversationId,
type
});
} catch (error) {
console.error("Error refetching queries after conversation state change:", error);
}
break; break;
}
case "tag-added": { case "tag-added": {
const formattedJobConversations = (job_conversations || []).map((jc) => ({ // Ensure `job_conversations` is properly formatted
const formattedJobConversations = job_conversations.map((jc) => ({
__typename: "job_conversations", __typename: "job_conversations",
jobid: jc.jobid || jc.job?.id, jobid: jc.jobid || jc.job?.id,
conversationid: conversationId, conversationid: conversationId,
job: { job: jc.job || {
__typename: "jobs", __typename: "jobs",
id: jc.job?.id, id: data.selectedJob.id,
ro_number: jc.job?.ro_number, ro_number: data.selectedJob.ro_number,
ownr_co_nm: jc.job?.ownr_co_nm, ownr_co_nm: data.selectedJob.ownr_co_nm,
ownr_fn: jc.job?.ownr_fn, ownr_fn: data.selectedJob.ownr_fn,
ownr_ln: jc.job?.ownr_ln ownr_ln: data.selectedJob.ownr_ln
} }
})); }));
client.cache.modify({ client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }), id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: { fields: {
job_conversations(existing = [], { readField }) { job_conversations: (existing = []) => {
const seen = new Set(existing.map((x) => readField("jobid", x)).filter(Boolean)); // Ensure no duplicates based on both `conversationid` and `jobid`
const incoming = formattedJobConversations.filter((x) => x.jobid && !seen.has(x.jobid)); const existingLinks = new Set(
return [...existing, ...incoming]; existing.map((jc) => {
const jobId = client.cache.readFragment({
id: client.cache.identify(jc),
fragment: gql`
fragment JobConversationLinkAdded on job_conversations {
jobid
conversationid
}
`
})?.jobid;
return `${jobId}:${conversationId}`; // Unique identifier for a job-conversation link
})
);
const newItems = formattedJobConversations.filter((jc) => {
const uniqueLink = `${jc.jobid}:${jc.conversationid}`;
return !existingLinks.has(uniqueLink);
});
return [...existing, ...newItems];
} }
} }
}); });
@@ -545,31 +412,46 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
break; break;
} }
case "tag-removed": { case "tag-removed":
const conversationCacheId = client.cache.identify({ __typename: "conversations", id: conversationId }); try {
const conversationCacheId = client.cache.identify({ __typename: "conversations", id: conversationId });
// Evict the specific cache entry for job_conversations
client.cache.evict({
id: conversationCacheId,
fieldName: "job_conversations"
});
// Garbage collect evicted entries
client.cache.gc();
logLocal("handleConversationChanged - tag removed - Refetched conversation list after state change", {
conversationId,
type
});
} catch (error) {
console.error("Error refetching queries after conversation state change: (Tag Removed)", error);
}
client.cache.evict({
id: conversationCacheId,
fieldName: "job_conversations"
});
client.cache.gc();
break; break;
}
default: { default:
logLocal("handleConversationChanged - Unhandled type", { type });
client.cache.modify({ client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }), id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: Object.fromEntries( fields: {
Object.entries(fields).map(([key, value]) => [key, (cached) => (value !== undefined ? value : cached)]) ...Object.fromEntries(
) Object.entries(fields).map(([key, value]) => [key, (cached) => (value !== undefined ? value : cached)])
)
}
}); });
}
} }
} catch (error) { } catch (error) {
console.error("Error handling conversation changes:", { type, error }); console.error("Error handling conversation changes:", { type, error });
} }
}; };
// Existing handler for phone number opt-out
const handlePhoneNumberOptedOut = async (data) => { const handlePhoneNumberOptedOut = async (data) => {
const { bodyshopid, phone_number } = data; const { bodyshopid, phone_number } = data;
logLocal("handlePhoneNumberOptedOut - Start", data); logLocal("handlePhoneNumberOptedOut - Start", data);
@@ -579,18 +461,22 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
id: "ROOT_QUERY", id: "ROOT_QUERY",
fields: { fields: {
phone_number_opt_out(existing = [], { readField }) { phone_number_opt_out(existing = [], { readField }) {
const exists = existing.some( const phoneNumberExists = existing.some(
(ref) => readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid (ref) => readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid
); );
if (exists) return existing;
if (phoneNumberExists) {
logLocal("handlePhoneNumberOptedOut - Phone number already in cache", { phone_number, bodyshopid });
return existing;
}
const newOptOut = { const newOptOut = {
__typename: "phone_number_opt_out", __typename: "phone_number_opt_out",
id: `temporary-${phone_number}-${Date.now()}`, id: `temporary-${phone_number}-${Date.now()}`,
bodyshopid, bodyshopid,
phone_number, phone_number,
created_at: safeIsoNow(), created_at: new Date().toISOString(),
updated_at: safeIsoNow() updated_at: new Date().toISOString()
}; };
return [...existing, newOptOut]; return [...existing, newOptOut];
@@ -605,36 +491,46 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
args: { bodyshopid, search: phone_number } args: { bodyshopid, search: phone_number }
}); });
client.cache.gc(); client.cache.gc();
logLocal("handlePhoneNumberOptedOut - Cache updated successfully", data);
} catch (error) { } catch (error) {
console.error("Error updating cache for phone number opt-out:", error); console.error("Error updating cache for phone number opt-out:", error);
logLocal("handlePhoneNumberOptedOut - Error", { error: error.message });
} }
}; };
// New handler for phone number opt-in
const handlePhoneNumberOptedIn = async (data) => { const handlePhoneNumberOptedIn = async (data) => {
const { bodyshopid, phone_number } = data; const { bodyshopid, phone_number } = data;
logLocal("handlePhoneNumberOptedIn - Start", data); logLocal("handlePhoneNumberOptedIn - Start", data);
try { try {
// Update the Apollo cache for GET_PHONE_NUMBER_OPT_OUTS by removing the phone number
client.cache.modify({ client.cache.modify({
id: "ROOT_QUERY", id: "ROOT_QUERY",
fields: { fields: {
phone_number_opt_out(existing = [], { readField }) { phone_number_opt_out(existing = [], { readField }) {
// Filter out the phone number from the opt-out list
return existing.filter( return existing.filter(
(ref) => !(readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid) (ref) => !(readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid)
); );
} }
}, },
broadcast: true broadcast: true // Trigger UI updates
}); });
// Evict the cache entry to force a refetch on next query
client.cache.evict({ client.cache.evict({
id: "ROOT_QUERY", id: "ROOT_QUERY",
fieldName: "phone_number_opt_out", fieldName: "phone_number_opt_out",
args: { bodyshopid, search: phone_number } args: { bodyshopid, search: phone_number }
}); });
client.cache.gc(); client.cache.gc();
logLocal("handlePhoneNumberOptedIn - Cache updated successfully", data);
} catch (error) { } catch (error) {
console.error("Error updating cache for phone number opt-in:", error); console.error("Error updating cache for phone number opt-in:", error);
logLocal("handlePhoneNumberOptedIn - Error", { error: error.message });
} }
}; };

View File

@@ -12,7 +12,7 @@ import _ from "lodash";
import { ExclamationCircleOutlined } from "@ant-design/icons"; import { ExclamationCircleOutlined } from "@ant-design/icons";
import "./chat-conversation-list.styles.scss"; import "./chat-conversation-list.styles.scss";
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import { GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS } from "../../graphql/phone-number-opt-out.queries.js"; import { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries.js";
import { phone } from "phone"; import { phone } from "phone";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
@@ -29,26 +29,13 @@ const mapDispatchToProps = (dispatch) => ({
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) { function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [, forceUpdate] = useState(false); const [, forceUpdate] = useState(false);
const phoneNumbers = conversationList.map((item) => phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, ""));
const phoneNumbers = useMemo(() => { const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUTS, {
return (conversationList || [])
.map((item) => {
try {
const p = phone(item.phone_num, "CA")?.phoneNumber;
return p ? p.replace(/^\+1/, "") : null;
} catch {
return null;
}
})
.filter(Boolean);
}, [conversationList]);
const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS, {
variables: { variables: {
bodyshopid: bodyshop?.id, bodyshopid: bodyshop.id,
phone_numbers: phoneNumbers phone_numbers: phoneNumbers
}, },
skip: !bodyshop?.id || phoneNumbers.length === 0, skip: !conversationList.length,
fetchPolicy: "cache-and-network" fetchPolicy: "cache-and-network"
}); });
@@ -71,25 +58,15 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
return _.orderBy(conversationList, ["updated_at"], ["desc"]); return _.orderBy(conversationList, ["updated_at"], ["desc"]);
}, [conversationList]); }, [conversationList]);
const renderConversation = (index) => { const renderConversation = (index, t) => {
const item = sortedConversationList[index]; const item = sortedConversationList[index];
const normalizedPhone = phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, "");
const normalizedPhone = (() => { const hasOptOutEntry = optOutMap.has(normalizedPhone);
try {
return phone(item.phone_num, "CA")?.phoneNumber?.replace(/^\+1/, "") || "";
} catch {
return "";
}
})();
const hasOptOutEntry = normalizedPhone ? optOutMap.has(normalizedPhone) : false;
const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>; const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
const cardContentLeft = const cardContentLeft =
item.job_conversations.length > 0 item.job_conversations.length > 0
? item.job_conversations.map((j, idx) => <Tag key={idx}>{j.job.ro_number}</Tag>) ? item.job_conversations.map((j, idx) => <Tag key={idx}>{j.job.ro_number}</Tag>)
: null; : null;
const names = <>{_.uniq(item.job_conversations.map((j) => OwnerNameDisplayFunction(j.job)))}</>; const names = <>{_.uniq(item.job_conversations.map((j) => OwnerNameDisplayFunction(j.job)))}</>;
const cardTitle = ( const cardTitle = (
<> <>
@@ -103,10 +80,9 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
)} )}
</> </>
); );
const cardExtra = ( const cardExtra = (
<> <>
<Badge count={item.messages_aggregate?.aggregate?.count || 0} /> <Badge count={item.messages_aggregate.aggregate.count} />
{hasOptOutEntry && ( {hasOptOutEntry && (
<Tooltip title={t("consent.text_body")}> <Tooltip title={t("consent.text_body")}>
<Tag color="red" icon={<ExclamationCircleOutlined />}> <Tag color="red" icon={<ExclamationCircleOutlined />}>
@@ -116,7 +92,6 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
)} )}
</> </>
); );
const getCardStyle = () => const getCardStyle = () =>
item.id === selectedConversation item.id === selectedConversation
? { backgroundColor: "var(--card-selected-bg)" } ? { backgroundColor: "var(--card-selected-bg)" }
@@ -129,8 +104,24 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`} className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
> >
<Card style={getCardStyle()} variant={true} size="small" extra={cardExtra} title={cardTitle}> <Card style={getCardStyle()} variant={true} size="small" extra={cardExtra} title={cardTitle}>
<div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div> <div
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div> style={{
display: "inline-block",
width: "70%",
textAlign: "left"
}}
>
{cardContentLeft}
</div>
<div
style={{
display: "inline-block",
width: "30%",
textAlign: "right"
}}
>
{cardContentRight}
</div>
</Card> </Card>
</List.Item> </List.Item>
); );
@@ -140,7 +131,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
<div className="chat-list-container"> <div className="chat-list-container">
<Virtuoso <Virtuoso
data={sortedConversationList} data={sortedConversationList}
itemContent={(index) => renderConversation(index)} itemContent={(index) => renderConversation(index, t)}
style={{ height: "100%", width: "100%" }} style={{ height: "100%", width: "100%" }}
/> />
</div> </div>

View File

@@ -5,24 +5,24 @@ import ChatConversationTitleTags from "../chat-conversation-title-tags/chat-conv
import ChatLabelComponent from "../chat-label/chat-label.component"; import ChatLabelComponent from "../chat-label/chat-label.component";
import ChatPrintButton from "../chat-print-button/chat-print-button.component"; import ChatPrintButton from "../chat-print-button/chat-print-button.component";
import ChatTagRoContainer from "../chat-tag-ro/chat-tag-ro.container"; import ChatTagRoContainer from "../chat-tag-ro/chat-tag-ro.container";
import ChatMarkUnreadButton from "../chat-mark-unread-button/chat-mark-unread-button.component"; import { createStructuredSelector } from "reselect";
import { connect } from "react-redux";
export function ChatConversationTitle({ conversation, onMarkUnread, markUnreadDisabled, markUnreadLoading }) { const mapStateToProps = createStructuredSelector({});
const mapDispatchToProps = () => ({});
export function ChatConversationTitle({ conversation }) {
return ( return (
<Space className="chat-title" wrap> <Space className="chat-title" wrap>
<PhoneNumberFormatter>{conversation?.phone_num}</PhoneNumberFormatter> <PhoneNumberFormatter>{conversation?.phone_num}</PhoneNumberFormatter>
<ChatLabelComponent conversation={conversation} /> <ChatLabelComponent conversation={conversation} />
<ChatPrintButton conversation={conversation} /> <ChatPrintButton conversation={conversation} />
<ChatConversationTitleTags jobConversations={conversation?.job_conversations || []} /> <ChatConversationTitleTags jobConversations={conversation?.job_conversations || []} />
<ChatTagRoContainer conversation={conversation || []} /> <ChatTagRoContainer conversation={conversation || []} />
<ChatMarkUnreadButton disabled={markUnreadDisabled} loading={markUnreadLoading} onMarkUnread={onMarkUnread} />
<ChatArchiveButton conversation={conversation} /> <ChatArchiveButton conversation={conversation} />
</Space> </Space>
); );
} }
export default ChatConversationTitle; export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationTitle);

View File

@@ -19,9 +19,7 @@ export function ChatConversationComponent({
conversation, conversation,
messages, messages,
handleMarkConversationAsRead, handleMarkConversationAsRead,
handleMarkLastMessageAsUnread, bodyshop
markingAsUnreadInProgress,
canMarkUnread
}) { }) {
const [loading, error] = subState; const [loading, error] = subState;
@@ -35,12 +33,7 @@ export function ChatConversationComponent({
onMouseDown={handleMarkConversationAsRead} onMouseDown={handleMarkConversationAsRead}
onKeyDown={handleMarkConversationAsRead} onKeyDown={handleMarkConversationAsRead}
> >
<ChatConversationTitle <ChatConversationTitle conversation={conversation} bodyshop={bodyshop} />
conversation={conversation}
onMarkUnread={handleMarkLastMessageAsUnread}
markUnreadDisabled={!canMarkUnread}
markUnreadLoading={markingAsUnreadInProgress}
/>
<ChatMessageListComponent messages={messages} /> <ChatMessageListComponent messages={messages} />
<ChatSendMessage conversation={conversation} /> <ChatSendMessage conversation={conversation} />
</div> </div>

View File

@@ -1,6 +1,6 @@
import { gql, useApolloClient, useQuery, useSubscription } from "@apollo/client"; import { gql, useApolloClient, useQuery, useSubscription } from "@apollo/client";
import axios from "axios"; import axios from "axios";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries"; import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
@@ -18,8 +18,8 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
const client = useApolloClient(); const client = useApolloClient();
const { socket } = useSocket(); const { socket } = useSocket();
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false); const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
const [markingAsUnreadInProgress, setMarkingAsUnreadInProgress] = useState(false);
// Fetch conversation details
const { const {
loading: convoLoading, loading: convoLoading,
error: convoError, error: convoError,
@@ -27,23 +27,24 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
} = useQuery(GET_CONVERSATION_DETAILS, { } = useQuery(GET_CONVERSATION_DETAILS, {
variables: { conversationId: selectedConversation }, variables: { conversationId: selectedConversation },
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only", nextFetchPolicy: "network-only"
skip: !selectedConversation
}); });
const conversation = convoData?.conversations_by_pk; // Subscription for conversation updates
// Subscription for conversation updates (used when socket is NOT connected)
useSubscription(CONVERSATION_SUBSCRIPTION_BY_PK, { useSubscription(CONVERSATION_SUBSCRIPTION_BY_PK, {
skip: socket?.connected || !selectedConversation, skip: socket?.connected,
variables: { conversationId: selectedConversation }, variables: { conversationId: selectedConversation },
onData: ({ data: subscriptionResult, client }) => { onData: ({ data: subscriptionResult, client }) => {
// Extract the messages array from the result
const messages = subscriptionResult?.data?.messages; const messages = subscriptionResult?.data?.messages;
if (!messages || messages.length === 0) return; if (!messages || messages.length === 0) {
console.warn("No messages found in subscription result.");
return;
}
messages.forEach((message) => { messages.forEach((message) => {
const messageRef = client.cache.identify(message); const messageRef = client.cache.identify(message);
// Write the new message to the cache
client.cache.writeFragment({ client.cache.writeFragment({
id: messageRef, id: messageRef,
fragment: gql` fragment: gql`
@@ -63,6 +64,7 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
data: message data: message
}); });
// Update the conversation cache to include the new message
client.cache.modify({ client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: selectedConversation }), id: client.cache.identify({ __typename: "conversations", id: selectedConversation }),
fields: { fields: {
@@ -80,28 +82,6 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
} }
}); });
/**
* Best-effort badge update:
* This assumes your list query uses messages_aggregate.aggregate.count as UNREAD inbound count.
* If its total messages, rename/create a dedicated unread aggregate in the list query and update that field instead.
*/
const setConversationUnreadCountBestEffort = useCallback(
(conversationId, unreadCount) => {
if (!conversationId) return;
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
messages_aggregate(existing) {
if (!existing?.aggregate) return existing;
return { ...existing, aggregate: { ...existing.aggregate, count: unreadCount } };
}
}
});
},
[client.cache]
);
const updateCacheWithReadMessages = useCallback( const updateCacheWithReadMessages = useCallback(
(conversationId, messageIds) => { (conversationId, messageIds) => {
if (!conversationId || !messageIds?.length) return; if (!conversationId || !messageIds?.length) return;
@@ -109,34 +89,13 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
messageIds.forEach((messageId) => { messageIds.forEach((messageId) => {
client.cache.modify({ client.cache.modify({
id: client.cache.identify({ __typename: "messages", id: messageId }), id: client.cache.identify({ __typename: "messages", id: messageId }),
fields: { read: () => true } fields: {
read: () => true
}
}); });
}); });
setConversationUnreadCountBestEffort(conversationId, 0);
}, },
[client.cache, setConversationUnreadCountBestEffort] [client.cache]
);
const applyUnreadStateWithMaxOneUnread = useCallback(
({ conversationId, lastUnreadMessageId, messageIdsMarkedRead = [], unreadCount = 1 }) => {
if (!conversationId || !lastUnreadMessageId) return;
messageIdsMarkedRead.forEach((id) => {
client.cache.modify({
id: client.cache.identify({ __typename: "messages", id }),
fields: { read: () => true }
});
});
client.cache.modify({
id: client.cache.identify({ __typename: "messages", id: lastUnreadMessageId }),
fields: { read: () => false }
});
setConversationUnreadCountBestEffort(conversationId, unreadCount);
},
[client.cache, setConversationUnreadCountBestEffort]
); );
// WebSocket event handlers // WebSocket event handlers
@@ -144,25 +103,20 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
if (!socket?.connected) return; if (!socket?.connected) return;
const handleConversationChange = (data) => { const handleConversationChange = (data) => {
if (data?.type === "conversation-marked-read") { if (data.type === "conversation-marked-read") {
updateCacheWithReadMessages(data.conversationId, data.messageIds); const { conversationId, messageIds } = data;
} updateCacheWithReadMessages(conversationId, messageIds);
if (data?.type === "conversation-marked-unread") {
applyUnreadStateWithMaxOneUnread({
conversationId: data.conversationId,
lastUnreadMessageId: data.lastUnreadMessageId,
messageIdsMarkedRead: data.messageIdsMarkedRead,
unreadCount: data.unreadCount
});
} }
}; };
socket.on("conversation-changed", handleConversationChange); socket.on("conversation-changed", handleConversationChange);
return () => socket.off("conversation-changed", handleConversationChange);
}, [socket, updateCacheWithReadMessages, applyUnreadStateWithMaxOneUnread]);
// Join/leave conversation via WebSocket return () => {
socket.off("conversation-changed", handleConversationChange);
};
}, [socket, updateCacheWithReadMessages]);
// Join and leave conversation via WebSocket
useEffect(() => { useEffect(() => {
if (!socket?.connected || !selectedConversation || !bodyshop?.id) return; if (!socket?.connected || !selectedConversation || !bodyshop?.id) return;
@@ -179,21 +133,15 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
}; };
}, [socket, bodyshop, selectedConversation]); }, [socket, bodyshop, selectedConversation]);
const inboundNonSystemMessages = useMemo(() => { // Mark conversation as read
const msgs = conversation?.messages || [];
return msgs
.filter((m) => m && !m.isoutbound && !m.is_system)
.slice()
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
}, [conversation?.messages]);
const canMarkUnread = inboundNonSystemMessages.length > 0;
const handleMarkConversationAsRead = async () => { const handleMarkConversationAsRead = async () => {
if (!conversation || markingAsReadInProgress) return; if (!convoData || markingAsReadInProgress) return;
const conversation = convoData.conversations_by_pk;
if (!conversation) return;
const unreadMessageIds = conversation.messages const unreadMessageIds = conversation.messages
?.filter((message) => !message.read && !message.isoutbound && !message.is_system) ?.filter((message) => !message.read && !message.isoutbound)
.map((message) => message.id); .map((message) => message.id);
if (unreadMessageIds?.length > 0) { if (unreadMessageIds?.length > 0) {
@@ -214,48 +162,12 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
} }
}; };
const handleMarkLastMessageAsUnread = async () => {
if (!conversation || markingAsUnreadInProgress) return;
if (!bodyshop?.id || !bodyshop?.imexshopid) return;
const lastInbound = inboundNonSystemMessages[inboundNonSystemMessages.length - 1];
if (!lastInbound?.id) return;
setMarkingAsUnreadInProgress(true);
try {
const res = await axios.post("/sms/markLastMessageUnread", {
conversationId: conversation.id,
imexshopid: bodyshop.imexshopid,
bodyshopid: bodyshop.id
});
const payload = res?.data || {};
if (payload.lastUnreadMessageId) {
applyUnreadStateWithMaxOneUnread({
conversationId: conversation.id,
lastUnreadMessageId: payload.lastUnreadMessageId,
messageIdsMarkedRead: payload.messageIdsMarkedRead || [],
unreadCount: typeof payload.unreadCount === "number" ? payload.unreadCount : 1
});
} else {
setConversationUnreadCountBestEffort(conversation.id, 0);
}
} catch (error) {
console.error("Error marking last message unread:", error.message);
} finally {
setMarkingAsUnreadInProgress(false);
}
};
return ( return (
<ChatConversationComponent <ChatConversationComponent
subState={[convoLoading, convoError]} subState={[convoLoading, convoError]}
conversation={conversation || {}} conversation={convoData?.conversations_by_pk || {}}
messages={conversation?.messages || []} messages={convoData?.conversations_by_pk?.messages || []}
handleMarkConversationAsRead={handleMarkConversationAsRead} handleMarkConversationAsRead={handleMarkConversationAsRead}
handleMarkLastMessageAsUnread={handleMarkLastMessageAsUnread}
markingAsUnreadInProgress={markingAsUnreadInProgress}
canMarkUnread={canMarkUnread}
/> />
); );
} }

View File

@@ -1,24 +0,0 @@
import { Button, Tooltip } from "antd";
import { useTranslation } from "react-i18next";
export default function ChatMarkUnreadButton({ disabled, loading, onMarkUnread }) {
const { t } = useTranslation();
return (
<Tooltip title={t("messaging.labels.mark_unread")}>
<Button
size="small"
className="unread-button"
type="primary"
children={t("messaging.labels.mark_unread")}
loading={loading}
disabled={disabled}
onMouseDown={(e) => e.stopPropagation()} // prevent parent mark-read handler
onClick={(e) => {
e.stopPropagation();
onMarkUnread?.();
}}
/>
</Tooltip>
);
}

View File

@@ -10,11 +10,6 @@
border-radius: 4px; border-radius: 4px;
} }
.unread-button {
height: 20px;
border-radius: 4px;
}
.chat-title { .chat-title {
margin-bottom: 5px; margin-bottom: 5px;
} }

View File

@@ -142,37 +142,17 @@ export default function JobLifecycleDashboardComponent({ data, bodyshop, ...card
title={t("job_lifecycle.content.legend_title")} title={t("job_lifecycle.content.legend_title")}
style={{ marginTop: "10px" }} style={{ marginTop: "10px" }}
> >
<div <div>
style={{
display: "flex",
flexWrap: "wrap",
gap: 8
}}
>
{lifecycleData.summations.map((key) => ( {lifecycleData.summations.map((key) => (
<Tag <Tag key={key.status} color={key.color} style={{ width: "13vh", padding: "4px", margin: "4px" }}>
key={key.status}
color={key.color}
style={{
// IMPORTANT: let the tag grow with its content
width: "auto",
padding: 0,
margin: 0,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
boxSizing: "border-box"
}}
>
<div <div
aria-label={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`} aria-label={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
title={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`} title={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
style={{ style={{
backgroundColor: "var(--tag-wrapper-bg)", backgroundColor: "var(--tag-wrapper-bg)",
color: "var(--tag-wrapper-text)", color: "var(--tag-wrapper-text)",
padding: "4px 8px", padding: "4px",
textAlign: "center", textAlign: "center"
whiteSpace: "nowrap" // keep it on one line while letting the pill expand
}} }}
> >
{key.status} [{lifecycleData.statusCounts[key.status]}] ({key.roundedPercentage}) {key.status} [{lifecycleData.statusCounts[key.status]}] ({key.roundedPercentage})

View File

@@ -55,7 +55,7 @@ export function DmsAllocationsSummaryAp({ socket, bodyshop, billids, title }) {
}); });
} }
}, [socket, socket.connected, billids]); }, [socket, socket.connected, billids]);
console.log(allocationsSummary);
const columns = [ const columns = [
{ {
title: t("general.labels.status"), title: t("general.labels.status"),
@@ -122,7 +122,7 @@ export function DmsAllocationsSummaryAp({ socket, bodyshop, billids, title }) {
<Form.Item <Form.Item
name="journal" name="journal"
label={t("jobs.fields.dms.journal")} label={t("jobs.fields.dms.journal")}
initialValue={bodyshop.cdk_configuration && bodyshop.cdk_configuration.default_journal} initialValue={bodyshop.cdk_configuration?.default_journal}
rules={[ rules={[
{ {
required: true required: true

View File

@@ -1,72 +1,97 @@
import { Alert, Button, Card, Table, Typography } from "antd"; import { Alert, Button, Card, Table, Typography } from "antd";
import { useEffect, useState } from "react"; import { SyncOutlined } from "@ant-design/icons";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import Dinero from "dinero.js"; import Dinero from "dinero.js";
import { SyncOutlined } from "@ant-design/icons"; import { DMS_MAP } from "../../utils/dmsUtils";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { pageLimit } from "../../utils/config"; import { pageLimit } from "../../utils/config";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = () => ({});
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(DmsAllocationsSummary); export default connect(mapStateToProps, mapDispatchToProps)(DmsAllocationsSummary);
export function DmsAllocationsSummary({ socket, bodyshop, jobId, title }) { /**
* DMS Allocations Summary component
* @param mode
* @param socket
* @param bodyshop
* @param jobId
* @param title
* @returns {JSX.Element}
* @constructor
*/
export function DmsAllocationsSummary({ mode, socket, bodyshop, jobId, title }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [allocationsSummary, setAllocationsSummary] = useState([]); const [allocationsSummary, setAllocationsSummary] = useState([]);
useEffect(() => { // Resolve event name by mode (PBS reuses the CDK event per existing behavior)
if (socket.connected) { const allocationsEvent =
socket.emit("cdk-calculate-allocations", jobId, (ack) => { mode === DMS_MAP.reynolds
setAllocationsSummary(ack); ? "rr-calculate-allocations"
socket.allocationsSummary = ack; : mode === DMS_MAP.fortellis
? "fortellis-calculate-allocations"
: /* "cdk" | "pbs" (legacy) */ "cdk-calculate-allocations";
const fetchAllocations = useCallback(() => {
if (!socket || !jobId || !mode) return;
try {
socket.emit(allocationsEvent, jobId, (ack) => {
const list = Array.isArray(ack) ? ack : [];
setAllocationsSummary(list);
// Preserve side-channel used by the post form for discrepancy checks
socket.allocationsSummary = list;
}); });
} catch {
// Best-effort; leave table empty on error
setAllocationsSummary([]);
socket && (socket.allocationsSummary = []);
} }
}, [socket, socket.connected, jobId]); }, [socket, jobId, mode, allocationsEvent]);
// Initial + whenever mode/socket/jobId changes
useEffect(() => {
fetchAllocations();
}, [fetchAllocations]);
const columns = [ const columns = [
{ { title: t("jobs.fields.dms.center"), dataIndex: "center", key: "center" },
title: t("jobs.fields.dms.center"),
dataIndex: "center",
key: "center"
},
{ {
title: t("jobs.fields.dms.sale"), title: t("jobs.fields.dms.sale"),
dataIndex: "sale", dataIndex: "sale",
key: "sale", key: "sale",
render: (text, record) => Dinero(record.sale).toFormat() render: (_text, record) => Dinero(record.sale).toFormat()
}, },
{ {
title: t("jobs.fields.dms.cost"), title: t("jobs.fields.dms.cost"),
dataIndex: "cost", dataIndex: "cost",
key: "cost", key: "cost",
render: (text, record) => Dinero(record.cost).toFormat() render: (_text, record) => Dinero(record.cost).toFormat()
}, },
{ {
title: t("jobs.fields.dms.sale_dms_acctnumber"), title: t("jobs.fields.dms.sale_dms_acctnumber"),
dataIndex: "sale_dms_acctnumber", dataIndex: "sale_dms_acctnumber",
key: "sale_dms_acctnumber", key: "sale_dms_acctnumber",
render: (text, record) => record.profitCenter?.dms_acctnumber render: (_text, record) => record.profitCenter?.dms_acctnumber
}, },
{ {
title: t("jobs.fields.dms.cost_dms_acctnumber"), title: t("jobs.fields.dms.cost_dms_acctnumber"),
dataIndex: "cost_dms_acctnumber", dataIndex: "cost_dms_acctnumber",
key: "cost_dms_acctnumber", key: "cost_dms_acctnumber",
render: (text, record) => record.costCenter?.dms_acctnumber render: (_text, record) => record.costCenter?.dms_acctnumber
}, },
{ {
title: t("jobs.fields.dms.dms_wip_acctnumber"), title: t("jobs.fields.dms.dms_wip_acctnumber"),
dataIndex: "dms_wip_acctnumber", dataIndex: "dms_wip_acctnumber",
key: "dms_wip_acctnumber", key: "dms_wip_acctnumber",
render: (text, record) => record.costCenter?.dms_wip_acctnumber render: (_text, record) => record.costCenter?.dms_wip_acctnumber
} }
]; ];
@@ -74,11 +99,7 @@ export function DmsAllocationsSummary({ socket, bodyshop, jobId, title }) {
<Card <Card
title={title} title={title}
extra={ extra={
<Button <Button onClick={fetchAllocations} aria-label={t("general.actions.refresh")}>
onClick={() => {
socket.emit("cdk-calculate-allocations", jobId, (ack) => setAllocationsSummary(ack));
}}
>
<SyncOutlined /> <SyncOutlined />
</Button> </Button>
} }
@@ -86,6 +107,7 @@ export function DmsAllocationsSummary({ socket, bodyshop, jobId, title }) {
{bodyshop.pbs_configuration?.disablebillwip && ( {bodyshop.pbs_configuration?.disablebillwip && (
<Alert type="warning" message={t("jobs.labels.dms.disablebillwip")} /> <Alert type="warning" message={t("jobs.labels.dms.disablebillwip")} />
)} )}
<Table <Table
pagination={{ position: "top", defaultPageSize: pageLimit }} pagination={{ position: "top", defaultPageSize: pageLimit }}
columns={columns} columns={columns}
@@ -94,34 +116,23 @@ export function DmsAllocationsSummary({ socket, bodyshop, jobId, title }) {
locale={{ emptyText: t("dms.labels.refreshallocations") }} locale={{ emptyText: t("dms.labels.refreshallocations") }}
scroll={{ x: true }} scroll={{ x: true }}
summary={() => { summary={() => {
const totals = const totals = allocationsSummary?.reduce(
allocationsSummary && (acc, val) => ({
allocationsSummary.reduce( totalSale: acc.totalSale.add(Dinero(val.sale)),
(acc, val) => { totalCost: acc.totalCost.add(Dinero(val.cost))
return { }),
totalSale: acc.totalSale.add(Dinero(val.sale)), { totalSale: Dinero(), totalCost: Dinero() }
totalCost: acc.totalCost.add(Dinero(val.cost)) ) || { totalSale: Dinero(), totalCost: Dinero() };
};
},
{
totalSale: Dinero(),
totalCost: Dinero()
}
);
return ( return (
<Table.Summary.Row> <Table.Summary.Row>
<Table.Summary.Cell> <Table.Summary.Cell>
<Typography.Title level={4}>{t("general.labels.totals")}</Typography.Title> <Typography.Title level={4}>{t("general.labels.totals")}</Typography.Title>
</Table.Summary.Cell> </Table.Summary.Cell>
<Table.Summary.Cell>{totals && totals.totalSale.toFormat()}</Table.Summary.Cell> <Table.Summary.Cell>{totals.totalSale.toFormat()}</Table.Summary.Cell>
<Table.Summary.Cell> <Table.Summary.Cell />
{ <Table.Summary.Cell />
// totals.totalCost.toFormat() <Table.Summary.Cell />
}
</Table.Summary.Cell>
<Table.Summary.Cell></Table.Summary.Cell>
<Table.Summary.Cell></Table.Summary.Cell>
</Table.Summary.Row> </Table.Summary.Row>
); );
}} }}

View File

@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser, currentUser: selectCurrentUser,
@@ -18,15 +19,26 @@ export default connect(mapStateToProps, mapDispatchToProps)(DmsCdkMakesRefetch);
export function DmsCdkMakesRefetch({ currentUser, bodyshop }) { export function DmsCdkMakesRefetch({ currentUser, bodyshop }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const {
treatments: { Fortellis }
} = useSplitTreatments({
attributes: {},
names: ["Fortellis"],
splitKey: bodyshop.imexshopid
});
if (!currentUser.email.includes("@imex.")) return null; if (!currentUser.email.includes("@imex.")) return null;
const handleRefetch = async () => { const handleRefetch = async () => {
setLoading(true); try {
await axios.post("/cdk/getvehicles", { setLoading(true);
cdk_dealerid: bodyshop.cdk_dealerid, await axios.post(`cdk${Fortellis.treatment === "on" ? "/fortellis" : ""}/getvehicles`, {
bodyshopid: bodyshop.id cdk_dealerid: bodyshop.cdk_dealerid,
}); bodyshopid: bodyshop.id
});
} catch (error) {
console.error(error);
}
setLoading(false); setLoading(false);
}; };

View File

@@ -0,0 +1,102 @@
import { Button, Checkbox, Col, Table } from "antd";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { alphaSort } from "../../utils/sorters";
export default function CDKCustomerSelector({ bodyshop, socket }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [customerList, setCustomerList] = useState([]);
const [selectedCustomer, setSelectedCustomer] = useState(null);
useEffect(() => {
if (!socket) return;
const handleCdkSelectCustomer = (list) => {
setOpen(true);
setCustomerList(Array.isArray(list) ? list : []);
setSelectedCustomer(null);
};
socket.on("cdk-select-customer", handleCdkSelectCustomer);
return () => {
socket.off("cdk-select-customer", handleCdkSelectCustomer);
};
}, [socket]);
const onUseSelected = () => {
if (!selectedCustomer) return;
setOpen(false);
socket.emit("cdk-selected-customer", selectedCustomer);
setSelectedCustomer(null);
};
const onUseGeneric = () => {
const generic = bodyshop.cdk_configuration?.generic_customer_number || null;
setOpen(false);
socket.emit("cdk-selected-customer", generic);
setSelectedCustomer(null);
};
const onCreateNew = () => {
setOpen(false);
socket.emit("cdk-selected-customer", null);
setSelectedCustomer(null);
};
if (!open) return null;
const columns = [
{ title: t("jobs.fields.dms.id"), dataIndex: ["id", "value"], key: "id" },
{
title: t("jobs.fields.dms.vinowner"),
dataIndex: "vinOwner",
key: "vinOwner",
render: (_t, r) => <Checkbox disabled checked={r.vinOwner} />
},
{
title: t("jobs.fields.dms.name1"),
dataIndex: ["name1", "fullName"],
key: "name1",
sorter: (a, b) => alphaSort(a.name1?.fullName, b.name1?.fullName)
},
{
title: t("jobs.fields.dms.address"),
key: "address",
render: (record) =>
`${record.address?.addressLine && record.address.addressLine[0]}, ${record.address?.city} ${
record.address?.stateOrProvince
} ${record.address?.postalCode}`
}
];
const rowKey = (r) => r.id?.value || r.customerId;
return (
<Col span={24}>
<Table
title={() => (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Button onClick={onUseSelected} disabled={!selectedCustomer}>
{t("jobs.actions.dms.useselected")}
</Button>
<Button onClick={onUseGeneric} disabled={!bodyshop.cdk_configuration?.generic_customer_number}>
{t("jobs.actions.dms.usegeneric")}
</Button>
<Button onClick={onCreateNew}>{t("jobs.actions.dms.createnewcustomer")}</Button>
</div>
)}
pagination={{ position: "top" }}
columns={columns}
rowKey={rowKey}
dataSource={customerList}
rowSelection={{
onSelect: (r) => {
const key = r.id?.value || r.customerId;
setSelectedCustomer(key ? String(key) : null);
},
type: "radio",
selectedRowKeys: selectedCustomer ? [selectedCustomer] : []
}}
/>
</Col>
);
}

View File

@@ -1,134 +1,54 @@
import { Button, Checkbox, Col, Table } from "antd"; import { useMemo } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { socket } from "../../pages/dms/dms.container";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import { alphaSort } from "../../utils/sorters";
import RRCustomerSelector from "./rr-customer-selector";
import FortellisCustomerSelector from "./fortellis-customer-selector";
import CDKCustomerSelector from "./cdk-customer-selector";
import PBSCustomerSelector from "./pbs-customer-selector";
import { DMS_MAP } from "../../utils/dmsUtils";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = () => ({ const mapDispatchToProps = () => ({});
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(DmsCustomerSelector); export default connect(mapStateToProps, mapDispatchToProps)(DmsCustomerSelector);
export function DmsCustomerSelector({ bodyshop }) { /**
const { t } = useTranslation(); * DMS Customer Selector component that renders the appropriate customer selector
const [customerList, setcustomerList] = useState([]); * @param props
const [open, setOpen] = useState(false); * @returns {JSX.Element|null}
const [selectedCustomer, setSelectedCustomer] = useState(null); * @constructor
const [dmsType, setDmsType] = useState("cdk"); */
export function DmsCustomerSelector(props) {
const { bodyshop, jobid, socket, rrOptions = {} } = props;
socket.on("cdk-select-customer", (customerList) => { // Centralized "mode" (provider + transport)
setOpen(true); const mode = props.mode;
setDmsType("cdk");
setcustomerList(customerList);
});
socket.on("pbs-select-customer", (customerList) => {
setOpen(true);
setDmsType("pbs");
setcustomerList(customerList);
});
const onUseSelected = () => { // Stable base props for children
setOpen(false); const base = useMemo(() => ({ bodyshop, jobid, socket }), [bodyshop, jobid, socket]);
socket.emit(`${dmsType}-selected-customer`, selectedCustomer);
setSelectedCustomer(null);
};
const onUseGeneric = () => { switch (mode) {
setOpen(false); case DMS_MAP.reynolds: {
socket.emit(`${dmsType}-selected-customer`, bodyshop.cdk_configuration.generic_customer_number); // Map rrOptions to current RR prop shape (you can also just pass rrOptions through and unpack in RR)
setSelectedCustomer(null); const rrProps = {
}; rrOpenRoLimit: rrOptions.openRoLimit,
onRrOpenRoFinished: rrOptions.onOpenRoFinished,
const onCreateNew = () => { rrValidationPending: rrOptions.validationPending,
setOpen(false); onValidationFinished: rrOptions.onValidationFinished
socket.emit(`${dmsType}-selected-customer`, null); };
setSelectedCustomer(null); return <RRCustomerSelector {...base} {...rrProps} />;
};
const cdkColumns = [
{
title: t("jobs.fields.dms.id"),
dataIndex: ["id", "value"],
key: "id"
},
{
title: t("jobs.fields.dms.vinowner"),
dataIndex: "vinOwner",
key: "vinOwner",
render: (text, record) => <Checkbox disabled checked={record.vinOwner} />
},
{
title: t("jobs.fields.dms.name1"),
dataIndex: ["name1", "fullName"],
key: "name1",
sorter: (a, b) => alphaSort(a.name1?.fullName, b.name1?.fullName)
},
{
title: t("jobs.fields.dms.address"),
//dataIndex: ["name2", "fullName"],
key: "address",
render: (record) =>
`${record.address?.addressLine && record.address.addressLine[0]}, ${record.address?.city} ${
record.address?.stateOrProvince
} ${record.address?.postalCode}`
} }
]; case DMS_MAP.fortellis:
return <FortellisCustomerSelector {...base} />;
const pbsColumns = [ case DMS_MAP.cdk:
{ return <CDKCustomerSelector {...base} />;
title: t("jobs.fields.dms.id"), case DMS_MAP.pbs:
dataIndex: "ContactId", return <PBSCustomerSelector {...base} />;
key: "ContactId" default:
}, return null;
{ }
title: t("jobs.fields.dms.name1"),
key: "name1",
sorter: (a, b) => alphaSort(a.LastName, b.LastName),
render: (text, record) => `${record.FirstName || ""} ${record.LastName || ""}`
},
{
title: t("jobs.fields.dms.address"),
key: "address",
render: (record) => `${record.Address}, ${record.City} ${record.State} ${record.ZipCode}`
}
];
if (!open) return null;
return (
<Col span={24}>
<Table
title={() => (
<div>
<Button onClick={onUseSelected} disabled={!selectedCustomer}>
{t("jobs.actions.dms.useselected")}
</Button>
<Button onClick={onUseGeneric} disabled={!bodyshop.cdk_configuration?.generic_customer_number}>
{t("jobs.actions.dms.usegeneric")}
</Button>
<Button onClick={onCreateNew}>{t("jobs.actions.dms.createnewcustomer")}</Button>
</div>
)}
pagination={{ position: "top" }}
columns={dmsType === "cdk" ? cdkColumns : pbsColumns}
rowKey={(record) => (dmsType === "cdk" ? record.id.value : record.ContactId)}
dataSource={customerList}
//onChange={handleTableChange}
rowSelection={{
onSelect: (record) => {
setSelectedCustomer(dmsType === "cdk" ? record.id.value : record.ContactId);
},
type: "radio",
selectedRowKeys: [selectedCustomer]
}}
/>
</Col>
);
} }

View File

@@ -0,0 +1,105 @@
import { Button, Checkbox, Col, Table } from "antd";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { alphaSort } from "../../utils/sorters";
export default function FortellisCustomerSelector({ bodyshop, jobid, socket }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [customerList, setCustomerList] = useState([]);
const [selectedCustomer, setSelectedCustomer] = useState(null);
useEffect(() => {
if (!socket) return;
const handleFortellisSelectCustomer = (list) => {
setOpen(true);
setCustomerList(Array.isArray(list) ? list : []);
setSelectedCustomer(null);
};
socket.on("fortellis-select-customer", handleFortellisSelectCustomer);
return () => {
socket.off("fortellis-select-customer", handleFortellisSelectCustomer);
};
}, [socket]);
const onUseSelected = () => {
if (!selectedCustomer) return;
setOpen(false);
socket.emit("fortellis-selected-customer", { selectedCustomerId: selectedCustomer, jobid });
setSelectedCustomer(null);
};
const onUseGeneric = () => {
const generic = bodyshop.cdk_configuration?.generic_customer_number || null;
setOpen(false);
socket.emit("fortellis-selected-customer", { selectedCustomerId: generic, jobid });
setSelectedCustomer(null);
};
const onCreateNew = () => {
setOpen(false);
socket.emit("fortellis-selected-customer", { selectedCustomerId: null, jobid });
setSelectedCustomer(null);
};
if (!open) return null;
const columns = [
{ title: t("jobs.fields.dms.id"), dataIndex: "customerId", key: "id" },
{
title: t("jobs.fields.dms.vinowner"),
dataIndex: "vinOwner",
key: "vinOwner",
render: (_t, r) => <Checkbox disabled checked={r.vinOwner} />
},
{
title: t("jobs.fields.dms.name1"),
dataIndex: ["customerName", "firstName"],
key: "firstName",
sorter: (a, b) => alphaSort(a.customerName?.firstName, b.customerName?.firstName)
},
{
title: t("jobs.fields.dms.name1"),
dataIndex: ["customerName", "lastName"],
key: "lastName",
sorter: (a, b) => alphaSort(a.customerName?.lastName, b.customerName?.lastName)
},
{
title: t("jobs.fields.dms.address"),
key: "address",
render: (record) =>
`${record.postalAddress?.addressLine1 || ""}${
record.postalAddress?.addressLine2 ? `, ${record.postalAddress.addressLine2}` : ""
}, ${record.postalAddress?.city || ""} ${record.postalAddress?.state || ""} ${
record.postalAddress?.postalCode || ""
} ${record.postalAddress?.country || ""}`
}
];
return (
<Col span={24}>
<Table
title={() => (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Button onClick={onUseSelected} disabled={!selectedCustomer}>
{t("jobs.actions.dms.useselected")}
</Button>
<Button onClick={onUseGeneric} disabled={!bodyshop.cdk_configuration?.generic_customer_number}>
{t("jobs.actions.dms.usegeneric")}
</Button>
<Button onClick={onCreateNew}>{t("jobs.actions.dms.createnewcustomer")}</Button>
</div>
)}
pagination={{ position: "top" }}
columns={columns}
rowKey={(r) => r.customerId}
dataSource={customerList}
rowSelection={{
onSelect: (r) => setSelectedCustomer(r?.customerId ? String(r.customerId) : null),
type: "radio",
selectedRowKeys: selectedCustomer ? [selectedCustomer] : []
}}
/>
</Col>
);
}

View File

@@ -0,0 +1,93 @@
import { Button, Col, Table } from "antd";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { alphaSort } from "../../utils/sorters";
export default function PBSCustomerSelector({ bodyshop, socket }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [customerList, setCustomerList] = useState([]);
const [selectedCustomer, setSelectedCustomer] = useState(null);
useEffect(() => {
if (!socket) return;
const handlePbsSelectCustomer = (list) => {
setOpen(true);
setCustomerList(Array.isArray(list) ? list : []);
setSelectedCustomer(null);
};
socket.on("pbs-select-customer", handlePbsSelectCustomer);
return () => {
socket.off("pbs-select-customer", handlePbsSelectCustomer);
};
}, [socket]);
const onUseSelected = () => {
if (!selectedCustomer) return;
setOpen(false);
socket.emit("pbs-selected-customer", selectedCustomer);
setSelectedCustomer(null);
};
// Restores old behavior: reuse the CDK-named generic number for PBS too,
// matching the previous single-component implementation.
const onUseGeneric = () => {
const generic = bodyshop?.cdk_configuration?.generic_customer_number || null;
if (!generic) return;
setOpen(false);
socket.emit("pbs-selected-customer", generic);
setSelectedCustomer(null);
};
const onCreateNew = () => {
setOpen(false);
socket.emit("pbs-selected-customer", null);
setSelectedCustomer(null);
};
if (!open) return null;
const columns = [
{ title: t("jobs.fields.dms.id"), dataIndex: "ContactId", key: "ContactId" },
{
title: t("jobs.fields.dms.name1"),
key: "name1",
sorter: (a, b) => alphaSort(a.LastName, b.LastName),
render: (_t, r) => `${r.FirstName || ""} ${r.LastName || ""}`
},
{
title: t("jobs.fields.dms.address"),
key: "address",
render: (r) => `${r.Address}, ${r.City} ${r.State} ${r.ZipCode}`
}
];
const hasGeneric = !!bodyshop?.cdk_configuration?.generic_customer_number;
return (
<Col span={24}>
<Table
title={() => (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Button onClick={onUseSelected} disabled={!selectedCustomer}>
{t("jobs.actions.dms.useselected")}
</Button>
<Button onClick={onUseGeneric} disabled={!hasGeneric}>
{t("jobs.actions.dms.usegeneric")}
</Button>
<Button onClick={onCreateNew}>{t("jobs.actions.dms.createnewcustomer")}</Button>
</div>
)}
pagination={{ position: "top" }}
columns={columns}
rowKey={(r) => r.ContactId}
dataSource={customerList}
rowSelection={{
onSelect: (r) => setSelectedCustomer(r?.ContactId ? String(r.ContactId) : null),
type: "radio",
selectedRowKeys: selectedCustomer ? [selectedCustomer] : []
}}
/>
</Col>
);
}

View File

@@ -0,0 +1,267 @@
import { Alert, Button, Checkbox, Col, message, Space, Table } from "antd";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { alphaSort } from "../../utils/sorters";
const normalizeRrList = (list) => {
if (!Array.isArray(list)) return [];
return list
.map((row) => {
const custNo = row.custNo || row.CustomerId || row.customerId || null;
const name =
row.name ||
[row.CustomerName?.FirstName, row.CustomerName?.LastName].filter(Boolean).join(" ").trim() ||
(custNo ? String(custNo) : "");
if (!custNo) return null;
const vinOwner = !!(row.vinOwner ?? row.isVehicleOwner);
const address =
row.address && typeof row.address === "object"
? {
line1: row.address.line1 ?? row.address.addr1 ?? row.address.Address1 ?? undefined,
line2: row.address.line2 ?? row.address.addr2 ?? row.address.Address2 ?? undefined,
city: row.address.city ?? undefined,
state: row.address.state ?? row.address.stateOrProvince ?? undefined,
postalCode: row.address.postalCode ?? row.address.zip ?? undefined,
country: row.address.country ?? row.address.countryCode ?? undefined
}
: undefined;
return { custNo: String(custNo), name, vinOwner, address };
})
.filter(Boolean);
};
const rrAddressToString = (addr) => {
if (!addr) return "";
const parts = [
addr.line1,
addr.line2,
[addr.city, addr.state].filter(Boolean).join(" "),
addr.postalCode,
addr.country
].filter(Boolean);
return parts.join(", ");
};
export default function RRCustomerSelector({
jobid,
socket,
rrOpenRoLimit = false,
onRrOpenRoFinished,
rrValidationPending = false,
onValidationFinished
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [customerList, setCustomerList] = useState([]);
const [selectedCustomer, setSelectedCustomer] = useState(null);
const [refreshing, setRefreshing] = useState(false);
// Show dialog automatically when validation is pending
useEffect(() => {
if (rrValidationPending) setOpen(true);
}, [rrValidationPending]);
// Listen for RR customer selection list
useEffect(() => {
if (!socket) return;
const handleRrSelectCustomer = (list) => {
const normalized = normalizeRrList(list);
setOpen(true);
setCustomerList(normalized);
const firstOwner = normalized.find((r) => r.vinOwner)?.custNo;
setSelectedCustomer(firstOwner ? String(firstOwner) : null);
setRefreshing(false);
};
socket.on("rr-select-customer", handleRrSelectCustomer);
return () => {
socket.off("rr-select-customer", handleRrSelectCustomer);
};
}, [socket]);
// VIN owner set
const rrOwnerSet = useMemo(() => {
return new Set(customerList.filter((c) => c?.vinOwner || c?.isVehicleOwner).map((c) => String(c.custNo)));
}, [customerList]);
const rrHasVinOwner = rrOwnerSet.size > 0;
// Enforce VIN owner stays selected if present
useEffect(() => {
if (!rrHasVinOwner) return;
const firstOwner = (customerList.find((c) => c.vinOwner) || {}).custNo;
if (firstOwner && String(selectedCustomer) !== String(firstOwner)) {
setSelectedCustomer(String(firstOwner));
}
}, [rrHasVinOwner, customerList, selectedCustomer]);
const onUseSelected = () => {
if (!selectedCustomer) {
message.warning(t("general.actions.select"));
return;
}
if (rrHasVinOwner && !rrOwnerSet.has(String(selectedCustomer))) {
message.warning(
"This VIN is already assigned in Reynolds. Only the VIN owner can be selected. To choose a different customer, change ownership in Reynolds first."
);
return;
}
socket.emit("rr-selected-customer", { jobId: jobid, custNo: String(selectedCustomer) }, (ack) => {
if (ack?.ok) {
message.success(t("dms.messages.customerSelected"));
} else if (ack?.error) {
message.error(ack.error);
}
});
};
const onCreateNew = () => {
if (rrHasVinOwner) return;
socket.emit("rr-selected-customer", { jobId: jobid, create: true }, (ack) => {
if (ack?.ok) {
if (ack.custNo) setSelectedCustomer(String(ack.custNo));
message.success(t("dms.messages.customerCreated"));
} else if (ack?.error) {
message.error(ack.error);
}
});
};
const refreshRrSearch = () => {
setRefreshing(true);
const to = setTimeout(() => setRefreshing(false), 12000);
const stop = () => {
clearTimeout(to);
setRefreshing(false);
socket.off("export-failed", stop);
socket.off("rr-select-customer", stop);
};
socket.once("rr-select-customer", stop);
socket.once("export-failed", stop);
socket.emit("rr-export-job", { jobId: jobid });
};
if (!open) return null;
const columns = [
{ title: t("jobs.fields.dms.id"), dataIndex: "custNo", key: "custNo" },
{
title: t("jobs.fields.dms.vinowner"),
dataIndex: "vinOwner",
key: "vinOwner",
render: (_t, r) => <Checkbox disabled checked={!!(r.vinOwner ?? r.isVehicleOwner)} />
},
{
title: t("jobs.fields.dms.name1"),
dataIndex: "name",
key: "name",
sorter: (a, b) => alphaSort(a?.name, b?.name)
},
{
title: t("jobs.fields.dms.address"),
key: "address",
render: (record) => rrAddressToString(record.address)
}
];
const rrDisableRow = (record) => {
if (!rrHasVinOwner) return false;
return !rrOwnerSet.has(String(record.custNo));
};
return (
<Col span={24}>
<Table
title={() => (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{/* Open RO limit banner */}
{rrOpenRoLimit && (
<Alert
type="error"
showIcon
message="Open RO limit reached in Reynolds"
description={
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div>
Reynolds has reached the maximum number of open Repair Orders for this Customer. Close or finalize
an RO in Reynolds, then click <strong>Finished</strong> to continue.
</div>
<div>
<Button type="primary" danger onClick={onRrOpenRoFinished}>
Finished
</Button>
</div>
</div>
}
/>
)}
{/* Validation step banner */}
{rrValidationPending && (
<Alert
type="info"
showIcon
message="Complete Validation in Reynolds"
description={
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div>
We created the Repair Order. Please validate the totals and taxes in the DMS system. When done,
click <strong>Finished</strong> to finalize and mark this export as complete.
</div>
<div>
<Space>
<Button type="primary" onClick={onValidationFinished}>
Finished
</Button>
</Space>
</div>
</div>
}
/>
)}
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Button onClick={onUseSelected} disabled={!selectedCustomer || rrOpenRoLimit}>
{t("jobs.actions.dms.useselected")}
</Button>
{/* No generic in RR */}
<Button onClick={onCreateNew} disabled={rrHasVinOwner}>
{t("jobs.actions.dms.createnewcustomer")}
</Button>
</div>
{rrHasVinOwner && (
<Alert
type="warning"
showIcon
message="VIN ownership enforced"
description={
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 }}>
<div>
This VIN is already assigned in Reynolds. Only the VIN owner is selectable here. To use a
different customer, please change the vehicle ownership in Reynolds first, then return to complete
the export.
</div>
<Button onClick={refreshRrSearch} loading={refreshing}>
Refresh
</Button>
</div>
}
/>
)}
</div>
)}
pagination={{ position: "top" }}
columns={columns}
rowKey={(r) => r.custNo}
dataSource={customerList}
rowSelection={{
onSelect: (record) => setSelectedCustomer(record?.custNo ? String(record.custNo) : null),
type: "radio",
selectedRowKeys: selectedCustomer ? [selectedCustomer] : [],
getCheckboxProps: (record) => ({ disabled: rrDisableRow(record) })
}}
/>
</Col>
);
}

View File

@@ -1,50 +1,225 @@
import { Divider, Space, Tag, Timeline } from "antd"; import { Divider, Space, Tag, Timeline } from "antd";
import { useEffect, useMemo, useState } from "react";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; import { selectDarkMode } from "../../redux/application/application.selectors.js";
const mapStateToProps = createStructuredSelector({}); const mapStateToProps = createStructuredSelector({
isDarkMode: selectDarkMode
const mapDispatchToProps = (dispatch) => ({
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
}); });
const mapDispatchToProps = () => ({});
export default connect(mapStateToProps, mapDispatchToProps)(DmsLogEvents); export default connect(mapStateToProps, mapDispatchToProps)(DmsLogEvents);
export function DmsLogEvents({ logs }) { export function DmsLogEvents({ logs, detailsOpen, detailsNonce, isDarkMode, colorizeJson = false }) {
return ( const [openSet, setOpenSet] = useState(() => new Set());
<Timeline
pending // Inject JSON highlight styles once (only when colorize is enabled)
reverse={true} useEffect(() => {
items={logs.map((log, idx) => ({ if (!colorizeJson) return;
key: idx, if (typeof document === "undefined") return;
color: LogLevelHierarchy(log.level), if (document.getElementById("json-highlight-styles")) return;
children: ( const style = document.createElement("style");
<Space wrap align="start" style={{}}> style.id = "json-highlight-styles";
<Tag color={LogLevelHierarchy(log.level)}>{log.level}</Tag> style.textContent = `
<span>{dayjs(log.timestamp).format("MM/DD/YYYY HH:mm:ss")}</span> .json-key { color: #fa8c16; }
<Divider type="vertical" /> .json-string { color: #52c41a; }
<span>{log.message}</span> .json-number { color: #722ed1; }
</Space> .json-boolean { color: #1890ff; }
) .json-null { color: #faad14; }
}))} `;
/> document.head.appendChild(style);
}, [colorizeJson]);
// Trim openSet if logs shrink
useEffect(() => {
const len = (logs || []).length;
setOpenSet((prev) => {
const next = new Set();
for (let i = 0; i < len; i++) if (prev.has(i)) next.add(i);
return next;
});
}, [logs?.length]);
// Respond to global toggle button
useEffect(() => {
if (detailsNonce == null) return;
const len = (logs || []).length;
setOpenSet(detailsOpen ? new Set(Array.from({ length: len }, (_, i) => i)) : new Set());
}, [detailsNonce, detailsOpen, logs?.length]);
const items = useMemo(
() =>
(logs || []).map((raw, idx) => {
const { level, message, timestamp, meta } = normalizeLog(raw);
const hasMeta = !isEmpty(meta);
const isOpen = openSet.has(idx);
return {
key: idx,
color: logLevelColor(level),
children: (
<Space direction="vertical" size={4} style={{ display: "flex" }}>
{/* Row 1: summary + inline "Details" toggle */}
<Space wrap align="start">
<Tag color={logLevelColor(level)}>{level}</Tag>
<Divider type="vertical" />
<span>{dayjs(timestamp).format("MM/DD/YYYY HH:mm:ss")}</span>
<Divider type="vertical" />
<span>{message}</span>
{hasMeta && (
<>
<Divider type="vertical" />
<a
role="button"
aria-expanded={isOpen}
onClick={() =>
setOpenSet((prev) => {
const next = new Set(prev);
if (isOpen) next.delete(idx);
else next.add(idx);
return next;
})
}
style={{ cursor: "pointer", userSelect: "none" }}
>
{isOpen ? "Hide details" : "Details"}
</a>
</>
)}
</Space>
{/* Row 2: details body (only when open) */}
{hasMeta && isOpen && (
<div style={{ marginLeft: 6 }}>
<JsonBlock isDarkMode={isDarkMode} data={meta} colorize={colorizeJson} />
</div>
)}
</Space>
)
};
}),
[logs, openSet, colorizeJson]
); );
return <Timeline pending reverse items={items} />;
} }
function LogLevelHierarchy(level) { /**
switch (level) { * Normalize various log input formats into a standard structure.
* @param input
* @returns {{level: string, message: *|string, timestamp: Date, meta: *}}
*/
const normalizeLog = (input) => {
const n = input?.normalized || input || {};
const level = (n.level || input?.level || "INFO").toString().toUpperCase();
const message = n.message ?? input?.message ?? "";
const meta = input?.meta != null ? input.meta : n.meta != null ? n.meta : undefined;
const tsRaw = input?.timestamp ?? n.timestamp ?? input?.ts ?? Date.now();
const timestamp = typeof tsRaw === "number" ? new Date(tsRaw) : new Date(tsRaw);
return { level, message, timestamp, meta };
};
/**
* Map log level to tag color.
* @param level
* @returns {string}
*/
const logLevelColor = (level) => {
switch ((level || "").toUpperCase()) {
case "SILLY":
return "purple";
case "DEBUG": case "DEBUG":
return "orange"; return "orange";
case "INFO": case "INFO":
return "blue"; return "blue";
case "WARN": case "WARN":
case "WARNING":
return "yellow"; return "yellow";
case "ERROR": case "ERROR":
return "red"; return "red";
default: default:
return 0; return "default";
} }
} };
/**
* Check if a value is "empty" (null/undefined, empty array, or empty object).
* @param v
*/
const isEmpty = (v) => {
if (v == null) return true;
if (Array.isArray(v)) return v.length === 0;
if (typeof v === "object") return Object.keys(v).length === 0;
return false;
};
/**
* Safely stringify an object to JSON, falling back to String() on failure.
* @param obj
* @param spaces
* @returns {string}
*/
const safeStringify = (obj, spaces = 2) => {
try {
return JSON.stringify(obj, null, spaces);
} catch {
return String(obj);
}
};
/**
* JSON display block with optional syntax highlighting.
* @param data
* @param colorize
* @param isDarkMode
* @returns {JSX.Element}
* @constructor
*/
const JsonBlock = ({ data, colorize, isDarkMode }) => {
const jsonText = safeStringify(data, 2);
const preStyle = {
margin: "6px 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"
};
if (colorize) {
const html = syntaxHighlight(jsonText);
return <pre style={preStyle} dangerouslySetInnerHTML={{ __html: html }} />;
}
return <pre style={preStyle}>{jsonText}</pre>;
};
/**
* Syntax highlight JSON text for HTML display.
* @param jsonText
* @returns {*}
*/
const syntaxHighlight = (jsonText) => {
const esc = jsonText.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return esc.replace(
/("(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
(match) => {
let cls = "json-number";
if (match.startsWith('"')) {
cls = match.endsWith(":") ? "json-key" : "json-string";
} else if (match === "true" || match === "false") {
cls = "json-boolean";
} else if (match === "null") {
cls = "json-null";
}
return `<span class="${cls}">${match}</span>`;
}
);
};

View File

@@ -0,0 +1,403 @@
import { DeleteFilled, DownOutlined } from "@ant-design/icons";
import {
Button,
Card,
Col,
Divider,
Dropdown,
Form,
Input,
InputNumber,
Row,
Select,
Space,
Statistic,
Switch,
Tooltip,
Typography
} from "antd";
import Dinero from "dinero.js";
import { useTranslation } from "react-i18next";
import { useMemo, useState } from "react";
import i18n from "../../translations/i18n";
import dayjs from "../../utils/day";
import DmsCdkMakes from "../dms-cdk-makes/dms-cdk-makes.component";
import DmsCdkMakesRefetch from "../dms-cdk-makes/dms-cdk-makes.refetch.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import { DMS_MAP } from "../../utils/dmsUtils";
/**
* CDK-like DMS post form:
* - CDK / Fortellis / PBS
* - CDK vehicle details + make/model selection
* - Payer list with discrepancy gating
* - Submit: "{mode}-export-job"
* @param bodyshop
* @param socket
* @param job
* @param logsRef
* @param mode
* @returns {JSX.Element}
* @constructor
*/
export default function CdkLikePostForm({ bodyshop, socket, job, logsRef, mode }) {
const [form] = Form.useForm();
const { t } = useTranslation();
const [, /*unused*/ setTick] = useState(0); // handy if you need a forceUpdate later
const initialValues = useMemo(
() => ({
story: `${t("jobs.labels.dms.defaultstory", {
ro_number: job.ro_number,
ownr_nm: `${job.ownr_fn || ""} ${job.ownr_ln || ""} ${job.ownr_co_nm || ""}`.trim(),
ins_co_nm: job.ins_co_nm || "N/A",
clm_po: `${job.clm_no ? `${job.clm_no} ` : ""}${job.po_number || ""}`
}).trim()}.${
job.area_of_damage?.impact1
? " " +
t("jobs.labels.dms.damageto", {
area_of_damage: (job.area_of_damage && job.area_of_damage.impact1.padStart(2, "0")) || "UNKNOWN"
})
: ""
}`.slice(0, 239),
inservicedate: dayjs(
`${
(job.v_model_yr &&
(job.v_model_yr < 100
? job.v_model_yr >= (dayjs().year() + 1) % 100
? 1900 + parseInt(job.v_model_yr, 10)
: 2000 + parseInt(job.v_model_yr, 10)
: job.v_model_yr)) ||
2019
}-01-01`
),
journal: bodyshop.cdk_configuration?.default_journal
}),
[job, bodyshop, t]
);
// Payers helpers
const handlePayerSelect = (value, index) => {
form.setFieldsValue({
payers: (form.getFieldValue("payers") || []).map((payer, mapIndex) => {
if (index !== mapIndex) return payer;
const cdkPayer =
bodyshop.cdk_configuration.payers && bodyshop.cdk_configuration.payers.find((i) => i.name === value);
if (!cdkPayer) return payer;
return {
...cdkPayer,
dms_acctnumber: cdkPayer.dms_acctnumber,
controlnumber: job?.[cdkPayer.control_type]
};
})
});
setTick((n) => n + 1);
};
const handleFinish = (values) => {
if (!socket) return;
if (mode === DMS_MAP.fortellis) {
socket.emit("fortellis-export-job", {
jobid: job.id,
txEnvelope: { ...values, SubscriptionID: bodyshop.cdk_dealerid }
});
} else {
socket.emit(`${mode}-export-job`, { jobid: job.id, txEnvelope: values });
}
logsRef?.current?.scrollIntoView({ behavior: "smooth" });
};
// Totals & discrepancy
const totals = socket?.allocationsSummary
? socket.allocationsSummary.reduce(
(acc, val) => ({
totalSale: acc.totalSale.add(Dinero(val.sale)),
totalCost: acc.totalCost.add(Dinero(val.cost))
}),
{ totalSale: Dinero(), totalCost: Dinero() }
)
: { totalSale: Dinero(), totalCost: Dinero() };
return (
<Card title={t("jobs.labels.dms.postingform")}>
<Form
form={form}
layout="vertical"
onFinish={handleFinish}
style={{ width: "100%" }}
initialValues={initialValues}
>
{/* TOP ROW */}
<Row gutter={[16, 12]} align="bottom">
<Col xs={24} sm={12} md={8} lg={6}>
<Form.Item name="journal" label={t("jobs.fields.dms.journal")} rules={[{ required: true }]}>
<Input />
</Form.Item>
</Col>
<Col xs={12} sm={8} md={6} lg={4}>
<Form.Item name="kmin" label={t("jobs.fields.kmin")} initialValue={job?.kmin} rules={[{ required: true }]}>
<InputNumber style={{ width: "100%" }} disabled />
</Form.Item>
</Col>
<Col xs={12} sm={8} md={6} lg={4}>
<Form.Item
name="kmout"
label={t("jobs.fields.kmout")}
initialValue={job?.kmout}
rules={[{ required: true }]}
>
<InputNumber style={{ width: "100%" }} disabled />
</Form.Item>
</Col>
</Row>
{/* CDK vehicle details (kept for CDK/Fortellis paths when dealer id exists) */}
{bodyshop.cdk_dealerid && (
<>
<Row gutter={[16, 12]}>
<Col xs={24} sm={12} md={8}>
<Form.Item name="dms_make" label={t("jobs.fields.dms.dms_make")} rules={[{ required: true }]}>
<Input disabled />
</Form.Item>
</Col>
<Col xs={24} sm={12} md={8}>
<Form.Item name="dms_model" label={t("jobs.fields.dms.dms_model")} rules={[{ required: true }]}>
<Input disabled />
</Form.Item>
</Col>
<Col xs={24} sm={12} md={8}>
<Form.Item name="inservicedate" label={t("jobs.fields.dms.inservicedate")}>
<DateTimePicker isDateOnly />
</Form.Item>
</Col>
</Row>
<Row gutter={[16, 12]} align="middle">
<Col>
<DmsCdkMakes form={form} job={job} />
</Col>
<Col>
<DmsCdkMakesRefetch />
</Col>
<Col>
<Form.Item name="dms_unsold" label={t("jobs.fields.dms.dms_unsold")} initialValue={false}>
<Switch />
</Form.Item>
</Col>
<Col>
<Form.Item
name="dms_model_override"
label={t("jobs.fields.dms.dms_model_override")}
initialValue={false}
>
<Switch />
</Form.Item>
</Col>
</Row>
</>
)}
<Row gutter={[16, 12]}>
<Col span={24}>
<Form.Item name="story" label={t("jobs.fields.dms.story")} rules={[{ required: true }]}>
<Input.TextArea maxLength={240} />
</Form.Item>
</Col>
</Row>
<Divider />
{/* Totals */}
<Space size="large" wrap align="center" style={{ marginBottom: 16 }}>
<Statistic
title={t("jobs.fields.ded_amt")}
value={Dinero(job.job_totals.totals.custPayable.deductible).toFormat()}
/>
<Statistic
title={t("jobs.labels.total_cust_payable")}
value={Dinero(job.job_totals.totals.custPayable.total).toFormat()}
/>
<Statistic
title={t("jobs.labels.net_repairs")}
value={Dinero(job.job_totals.totals.net_repairs).toFormat()}
/>
</Space>
{/* Payers list */}
<Divider />
<Form.List name={["payers"]}>
{(fields, { add, remove }) => (
<div>
{fields.map((field, index) => (
<Card
key={field.key}
size="small"
style={{ marginBottom: 12 }}
title={`${t("jobs.fields.dms.payer.payer_type")} #${index + 1}`}
extra={
<Tooltip title={t("general.actions.remove", "Remove")}>
<Button
type="text"
danger
icon={<DeleteFilled />}
aria-label={t("general.actions.remove", "Remove")}
onClick={() => remove(field.name)}
/>
</Tooltip>
}
>
<Row gutter={[16, 8]} align="middle">
<Col xs={24} sm={12} md={8} lg={6}>
<Form.Item
label={t("jobs.fields.dms.payer.name")}
name={[field.name, "name"]}
rules={[{ required: true }]}
>
<Select style={{ minWidth: "15rem" }} onSelect={(value) => handlePayerSelect(value, index)}>
{bodyshop.cdk_configuration?.payers?.map((payer) => (
<Select.Option key={payer.name}>{payer.name}</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col xs={24} sm={12} md={8} lg={6}>
<Form.Item
label={t("jobs.fields.dms.payer.dms_acctnumber")}
name={[field.name, "dms_acctnumber"]}
rules={[{ required: true }]}
>
<Input disabled />
</Form.Item>
</Col>
<Col xs={24} sm={12} md={8} lg={5}>
<Form.Item
label={t("jobs.fields.dms.payer.amount")}
name={[field.name, "amount"]}
rules={[{ required: true }]}
>
<CurrencyInput min={0} />
</Form.Item>
</Col>
<Col xs={24} sm={12} md={10} lg={7}>
<Form.Item
label={
<div>
{t("jobs.fields.dms.payer.controlnumber")}{" "}
<Dropdown
trigger={["click"]}
menu={{
items:
bodyshop.cdk_configuration.controllist?.map((key, idx) => ({
key: idx,
label: key.name,
onClick: () => {
form.setFieldsValue({
payers: (form.getFieldValue("payers") || []).map((row, mapIndex) => {
if (index !== mapIndex) return row;
return { ...row, controlnumber: key.controlnumber };
})
});
}
})) ?? []
}}
>
<a href="#" onClick={(e) => e.preventDefault()}>
<DownOutlined />
</a>
</Dropdown>
</div>
}
name={[field.name, "controlnumber"]}
rules={[{ required: true }]}
>
<Input />
</Form.Item>
</Col>
<Col xs={24}>
<Form.Item shouldUpdate noStyle>
{() => {
const payers = form.getFieldValue("payers");
const row = payers?.[index];
const cdkPayer =
bodyshop.cdk_configuration.payers &&
bodyshop.cdk_configuration.payers.find((i) => i && row && i.name === row.name);
if (i18n.exists(`jobs.fields.${cdkPayer?.control_type}`))
return <div>{cdkPayer && t(`jobs.fields.${cdkPayer?.control_type}`)}</div>;
else if (i18n.exists(`jobs.fields.dms.control_type.${cdkPayer?.control_type}`)) {
return <div>{cdkPayer && t(`jobs.fields.dms.control_type.${cdkPayer?.control_type}`)}</div>;
} else {
return null;
}
}}
</Form.Item>
</Col>
</Row>
</Card>
))}
<Form.Item>
<Button
disabled={!(fields.length < 3)}
onClick={() => {
if (fields.length < 3) add();
}}
style={{ width: "100%" }}
>
{t("jobs.actions.dms.addpayer")}
</Button>
</Form.Item>
</div>
)}
</Form.List>
{/* Validation gates & summary */}
<Form.Item shouldUpdate>
{() => {
let totalAllocated = Dinero();
const payers = form.getFieldValue("payers") || [];
payers.forEach((payer) => {
totalAllocated = totalAllocated.add(Dinero({ amount: Math.round((payer?.amount || 0) * 100) }));
});
const discrep = totals ? totals.totalSale.subtract(totalAllocated) : Dinero();
// gate: must have payers filled + zero discrepancy when we have a summary
const payersOk =
payers.length > 0 &&
payers.every((p) => p?.name && p.dms_acctnumber && (p.amount ?? "") !== "" && p.controlnumber);
const nonRrDiscrepancyGate = socket?.allocationsSummary ? discrep.getAmount() !== 0 : true;
const disablePost = !payersOk || nonRrDiscrepancyGate;
return (
<Space size="large" wrap align="center">
<Statistic
title={t("jobs.labels.subtotal")}
value={(totals ? totals.totalSale : Dinero()).toFormat()}
/>
<Typography.Title>-</Typography.Title>
<Statistic title={t("jobs.labels.dms.totalallocated")} value={totalAllocated.toFormat()} />
<Typography.Title>=</Typography.Title>
<Statistic
title={t("jobs.labels.dms.notallocated")}
valueStyle={{ color: discrep.getAmount() === 0 ? "green" : "red" }}
value={discrep.toFormat()}
/>
<Button disabled={disablePost} htmlType="submit">
{t("jobs.actions.dms.post")}
</Button>
</Space>
);
}}
</Form.Item>
</Form>
</Card>
);
}

View File

@@ -1,396 +1,40 @@
import { DeleteFilled, DownOutlined } from "@ant-design/icons";
import {
Button,
Card,
Divider,
Dropdown,
Form,
Input,
InputNumber,
Select,
Space,
Statistic,
Switch,
Typography
} from "antd";
import Dinero from "dinero.js";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { determineDmsType } from "../../pages/dms/dms.container";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import i18n from "../../translations/i18n"; import { DMS_MAP } from "../../utils/dmsUtils";
import dayjs from "../../utils/day"; import RRPostForm from "./rr-dms-post-form";
import DmsCdkMakes from "../dms-cdk-makes/dms-cdk-makes.component"; import CdkLikePostForm from "./cdklike-dms-post-form";
import DmsCdkMakesRefetch from "../dms-cdk-makes/dms-cdk-makes.refetch.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = () => ({ const mapDispatchToProps = () => ({});
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(DmsPostForm); export default connect(mapStateToProps, mapDispatchToProps)(DmsPostForm);
export function DmsPostForm({ bodyshop, socket, job, logsRef }) { /**
const [form] = Form.useForm(); * DMS Post Form component that renders the appropriate post form
const { t } = useTranslation(); * @param mode
* @param bodyshop
* @param socket
* @param job
* @param logsRef
* @returns {JSX.Element|null}
* @constructor
*/
export function DmsPostForm({ mode, bodyshop, socket, job, logsRef }) {
switch (mode) {
case DMS_MAP.reynolds:
return <RRPostForm bodyshop={bodyshop} socket={socket} job={job} logsRef={logsRef} />;
const handlePayerSelect = (value, index) => { // CDK (legacy /ws), Fortellis (CDK-over-WSS), and PBS share the same UI;
form.setFieldsValue({ // we pass mode down so the child can choose the correct event name.
payers: form.getFieldValue("payers").map((payer, mapIndex) => { case DMS_MAP.fortellis:
if (index !== mapIndex) return payer; case DMS_MAP.cdk:
const cdkPayer = case DMS_MAP.pbs:
bodyshop.cdk_configuration.payers && bodyshop.cdk_configuration.payers.find((i) => i.name === value); return <CdkLikePostForm mode={mode} bodyshop={bodyshop} socket={socket} job={job} logsRef={logsRef} />;
if (!cdkPayer) return payer; default:
return null;
return { }
...cdkPayer,
dms_acctnumber: cdkPayer.dms_acctnumber,
controlnumber: job && job[cdkPayer.control_type]
};
})
});
};
const handleFinish = (values) => {
socket.emit(`${determineDmsType(bodyshop)}-export-job`, {
jobid: job.id,
txEnvelope: values
});
console.log(logsRef);
if (logsRef) {
console.log("executing", logsRef);
logsRef.curent &&
logsRef.current.scrollIntoView({
behavior: "smooth"
});
}
};
return (
<Card title={t("jobs.labels.dms.postingform")}>
<Form
form={form}
layout="vertical"
onFinish={handleFinish}
initialValues={{
story: `${t("jobs.labels.dms.defaultstory", {
ro_number: job.ro_number,
ownr_nm: `${job.ownr_fn || ""} ${job.ownr_ln || ""} ${job.ownr_co_nm || ""}`.trim(),
ins_co_nm: job.ins_co_nm || "N/A",
clm_po: `${job.clm_no ? `${job.clm_no} ` : ""}${job.po_number || ""}`
}).trim()}.${
job.area_of_damage && job.area_of_damage.impact1
? " " +
t("jobs.labels.dms.damageto", {
area_of_damage: (job.area_of_damage && job.area_of_damage.impact1.padStart(2, "0")) || "UNKNOWN"
})
: ""
}`.slice(0, 239),
inservicedate: dayjs(
`${(job.v_model_yr && (job.v_model_yr < 100 ? (job.v_model_yr >= (dayjs().year() + 1) % 100 ? 1900 + parseInt(job.v_model_yr) : 2000 + parseInt(job.v_model_yr)) : job.v_model_yr)) || 2019}-01-01`
)
}}
>
<LayoutFormRow grow>
<Form.Item
name="journal"
label={t("jobs.fields.dms.journal")}
initialValue={bodyshop.cdk_configuration && bodyshop.cdk_configuration.default_journal}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
name="kmin"
label={t("jobs.fields.kmin")}
initialValue={job && job.kmin}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber disabled />
</Form.Item>
<Form.Item
name="kmout"
label={t("jobs.fields.kmout")}
initialValue={job && job.kmout}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber disabled />
</Form.Item>
</LayoutFormRow>
{bodyshop.cdk_dealerid && (
<div>
<LayoutFormRow style={{ justifyContent: "center" }} grow>
<Form.Item
name="dms_make"
label={t("jobs.fields.dms.dms_make")}
rules={[
{
required: true
}
]}
>
<Input disabled />
</Form.Item>
<Form.Item
name="dms_model"
label={t("jobs.fields.dms.dms_model")}
rules={[
{
required: true
}
]}
>
<Input disabled />
</Form.Item>
<Form.Item name="inservicedate" label={t("jobs.fields.dms.inservicedate")}>
<DateTimePicker isDateOnly />
</Form.Item>
</LayoutFormRow>
<Space>
<DmsCdkMakes form={form} socket={socket} job={job} />
<DmsCdkMakesRefetch />
<Form.Item name="dms_unsold" label={t("jobs.fields.dms.dms_unsold")} initialValue={false}>
<Switch />
</Form.Item>
<Form.Item name="dms_model_override" label={t("jobs.fields.dms.dms_model_override")} initialValue={false}>
<Switch />
</Form.Item>
</Space>
</div>
)}
<Form.Item
name="story"
label={t("jobs.fields.dms.story")}
rules={[
{
required: true
}
]}
>
<Input.TextArea maxLength={240} />
</Form.Item>
<Divider />
<Space size="large" wrap align="center">
<Statistic
title={t("jobs.fields.ded_amt")}
value={Dinero(job.job_totals.totals.custPayable.deductible).toFormat()}
/>
<Statistic
title={t("jobs.labels.total_cust_payable")}
value={Dinero(job.job_totals.totals.custPayable.total).toFormat()}
/>
<Statistic
title={t("jobs.labels.net_repairs")}
value={Dinero(job.job_totals.totals.net_repairs).toFormat()}
/>
</Space>
<Form.List name={["payers"]}>
{(fields, { add, remove }) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<Space wrap>
<Form.Item
label={t("jobs.fields.dms.payer.name")}
key={`${index}name`}
name={[field.name, "name"]}
rules={[
{
required: true
}
]}
>
<Select style={{ minWidth: "15rem" }} onSelect={(value) => handlePayerSelect(value, index)}>
{bodyshop.cdk_configuration &&
bodyshop.cdk_configuration.payers &&
bodyshop.cdk_configuration.payers.map((payer) => (
<Select.Option key={payer.name}>{payer.name}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label={t("jobs.fields.dms.payer.dms_acctnumber")}
key={`${index}dms_acctnumber`}
name={[field.name, "dms_acctnumber"]}
rules={[
{
required: true
}
]}
>
<Input disabled />
</Form.Item>
<Form.Item
label={t("jobs.fields.dms.payer.amount")}
key={`${index}amount`}
name={[field.name, "amount"]}
rules={[
{
required: true
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={
<div>
{t("jobs.fields.dms.payer.controlnumber")}{" "}
<Dropdown
menu={{
items: bodyshop.cdk_configuration.controllist?.map((key, idx) => ({
key: idx,
label: key.name,
onClick: () => {
form.setFieldsValue({
payers: form.getFieldValue("payers").map((row, mapIndex) => {
if (index !== mapIndex) return row;
return {
...row,
controlnumber: key.controlnumber
};
})
});
}
}))
}}
>
<a href=" #" onClick={(e) => e.preventDefault()}>
<DownOutlined />
</a>
</Dropdown>
</div>
}
key={`${index}controlnumber`}
name={[field.name, "controlnumber"]}
rules={[
{
required: true
}
]}
>
<Input />
</Form.Item>
<Form.Item shouldUpdate>
{() => {
const payers = form.getFieldValue("payers");
const row = payers && payers[index];
const cdkPayer =
bodyshop.cdk_configuration.payers &&
bodyshop.cdk_configuration.payers.find((i) => i && row && i.name === row.name);
if (i18n.exists(`jobs.fields.${cdkPayer?.control_type}`))
return <div>{cdkPayer && t(`jobs.fields.${cdkPayer?.control_type}`)}</div>;
else if (i18n.exists(`jobs.fields.dms.control_type.${cdkPayer?.control_type}`)) {
return <div>{cdkPayer && t(`jobs.fields.dms.control_type.${cdkPayer?.control_type}`)}</div>;
} else {
return null;
}
}}
</Form.Item>
<DeleteFilled
onClick={() => {
remove(field.name);
}}
/>
</Space>
</Form.Item>
))}
<Form.Item>
<Button
disabled={!(fields.length < 3)}
onClick={() => {
if (fields.length < 3) add();
}}
style={{ width: "100%" }}
>
{t("jobs.actions.dms.addpayer")}
</Button>
</Form.Item>
</div>
);
}}
</Form.List>
<Form.Item shouldUpdate>
{() => {
//Perform Calculation to determine discrepancy.
let totalAllocated = Dinero();
const payers = form.getFieldValue("payers");
payers &&
payers.forEach((payer) => {
totalAllocated = totalAllocated.add(Dinero({ amount: Math.round((payer?.amount || 0) * 100) }));
});
const totals =
socket.allocationsSummary &&
socket.allocationsSummary.reduce(
(acc, val) => {
return {
totalSale: acc.totalSale.add(Dinero(val.sale)),
totalCost: acc.totalCost.add(Dinero(val.cost))
};
},
{
totalSale: Dinero(),
totalCost: Dinero()
}
);
const discrep = totals ? totals.totalSale.subtract(totalAllocated) : Dinero();
return (
<Space size="large" wrap align="center">
<Statistic
title={t("jobs.labels.subtotal")}
value={(totals ? totals.totalSale : Dinero()).toFormat()}
/>
<Typography.Title>-</Typography.Title>
<Statistic title={t("jobs.labels.dms.totalallocated")} value={totalAllocated.toFormat()} />
<Typography.Title>=</Typography.Title>
<Statistic
title={t("jobs.labels.dms.notallocated")}
valueStyle={{
color: discrep.getAmount() === 0 ? "green" : "red"
}}
value={discrep.toFormat()}
/>
<Button disabled={!socket.allocationsSummary || discrep.getAmount() !== 0} htmlType="submit">
{t("jobs.actions.dms.post")}
</Button>
</Space>
);
}}
</Form.Item>
</Form>
</Card>
);
} }

View File

@@ -0,0 +1,242 @@
import { ReloadOutlined } from "@ant-design/icons";
import {
Button,
Card,
Col,
Divider,
Form,
Input,
InputNumber,
Row,
Select,
Space,
Statistic,
Tooltip,
Typography
} from "antd";
import Dinero from "dinero.js";
import { useTranslation } from "react-i18next";
import { useEffect, useMemo, useState } from "react";
import dayjs from "../../utils/day";
/**
* RR DMS Post Form component
* Submit: "rr-export-job"
* @param bodyshop
* @param socket
* @param job
* @param logsRef
* @returns {JSX.Element}
* @constructor
*/
export default function RRPostForm({ bodyshop, socket, job, logsRef }) {
const [form] = Form.useForm();
const { t } = useTranslation();
// Advisors
const [advisors, setAdvisors] = useState([]);
const [advLoading, setAdvLoading] = useState(false);
const getAdvisorNumber = (a) => a?.advisorId;
const getAdvisorLabel = (a) => `${a?.firstName || ""} ${a?.lastName || ""}`.trim();
const fetchRrAdvisors = (refresh = false) => {
if (!socket) return;
setAdvLoading(true);
const onResult = (payload) => {
try {
const list = payload?.result ?? payload ?? [];
setAdvisors(Array.isArray(list) ? list : []);
} finally {
setAdvLoading(false);
socket.off("rr-get-advisors:result", onResult);
}
};
socket.once("rr-get-advisors:result", onResult);
socket.emit("rr-get-advisors", { departmentType: "B", refresh }, (ack) => {
if (ack?.ok) {
const list = ack.result ?? [];
setAdvisors(Array.isArray(list) ? list : []);
} else if (ack) {
console.error("Something went wrong fetching DMS Advisors");
}
setAdvLoading(false);
socket.off("rr-get-advisors:result", onResult);
});
};
useEffect(() => {
fetchRrAdvisors(false);
}, [bodyshop?.id, socket]);
const initialValues = useMemo(
() => ({
story: `${t("jobs.labels.dms.defaultstory", {
ro_number: job.ro_number,
ownr_nm: `${job.ownr_fn || ""} ${job.ownr_ln || ""} ${job.ownr_co_nm || ""}`.trim(),
ins_co_nm: job.ins_co_nm || "N/A",
clm_po: `${job.clm_no ? `${job.clm_no} ` : ""}${job.po_number || ""}`
}).trim()}.${
job.area_of_damage?.impact1
? " " +
t("jobs.labels.dms.damageto", {
area_of_damage: (job.area_of_damage && job.area_of_damage.impact1.padStart(2, "0")) || "UNKNOWN"
})
: ""
}`.slice(0, 239),
inservicedate: dayjs(
`${
(job.v_model_yr &&
(job.v_model_yr < 100
? job.v_model_yr >= (dayjs().year() + 1) % 100
? 1900 + parseInt(job.v_model_yr, 10)
: 2000 + parseInt(job.v_model_yr, 10)
: job.v_model_yr)) ||
2019
}-01-01`
)
}),
[job, t]
);
const handleFinish = (values) => {
if (!socket) return;
socket.emit("rr-export-job", {
bodyshopId: bodyshop?.id,
jobId: job.id,
job,
txEnvelope: values
});
logsRef?.current?.scrollIntoView({ behavior: "smooth" });
};
// Discrepancy is ignored for RR; we still show totals for operator context
const totals = socket?.allocationsSummary
? socket.allocationsSummary.reduce(
(acc, val) => ({
totalSale: acc.totalSale.add(Dinero(val.sale)),
totalCost: acc.totalCost.add(Dinero(val.cost))
}),
{ totalSale: Dinero(), totalCost: Dinero() }
)
: { totalSale: Dinero(), totalCost: Dinero() };
return (
<Card title={t("jobs.labels.dms.postingform")}>
<Form
form={form}
layout="vertical"
onFinish={handleFinish}
style={{ width: "100%" }}
initialValues={initialValues}
>
<Row gutter={[16, 12]} align="bottom">
{/* Advisor + inline Refresh */}
<Col xs={24} sm={24} md={12} lg={8}>
<Form.Item label={t("jobs.fields.dms.advisor")} required>
<Space.Compact block>
<Form.Item
name="advisorNo"
noStyle
rules={[{ required: true, message: t("general.validation.required") }]}
>
<Select
style={{ flex: 1 }}
loading={advLoading}
allowClear
placeholder={t("general.actions.select", "Select...")}
popupMatchSelectWidth
options={advisors
.map((a) => {
const value = getAdvisorNumber(a);
if (value == null) return null;
return { value: String(value), label: getAdvisorLabel(a) || String(value) };
})
.filter(Boolean)}
notFoundContent={advLoading ? t("general.labels.loading") : t("general.labels.none")}
/>
</Form.Item>
<Tooltip title={t("general.actions.refresh")}>
<Button
aria-label={t("general.actions.refresh")}
icon={<ReloadOutlined />}
onClick={() => fetchRrAdvisors(true)}
loading={advLoading}
/>
</Tooltip>
</Space.Compact>
</Form.Item>
</Col>
{/* Make Override */}
<Col xs={24} sm={12} md={12} lg={8}>
<Form.Item name="makeOverride" label={t("jobs.fields.dms.make_override")}>
<Input allowClear placeholder={t("general.actions.optional")} />
</Form.Item>
</Col>
<Col xs={12} sm={8} md={6} lg={4}>
<Form.Item name="kmin" label={t("jobs.fields.kmin")} initialValue={job?.kmin} rules={[{ required: true }]}>
<InputNumber style={{ width: "100%" }} disabled />
</Form.Item>
</Col>
<Col xs={12} sm={8} md={6} lg={4}>
<Form.Item
name="kmout"
label={t("jobs.fields.kmout")}
initialValue={job?.kmout}
rules={[{ required: true }]}
>
<InputNumber style={{ width: "100%" }} disabled />
</Form.Item>
</Col>
</Row>
<Row gutter={[16, 12]}>
<Col span={24}>
<Form.Item name="story" label={t("jobs.fields.dms.story")} rules={[{ required: true }]}>
<Input.TextArea maxLength={240} />
</Form.Item>
</Col>
</Row>
<Divider />
<Space size="large" wrap align="center" style={{ marginBottom: 16 }}>
<Statistic
title={t("jobs.fields.ded_amt")}
value={Dinero(job.job_totals.totals.custPayable.deductible).toFormat()}
/>
<Statistic
title={t("jobs.labels.total_cust_payable")}
value={Dinero(job.job_totals.totals.custPayable.total).toFormat()}
/>
<Statistic
title={t("jobs.labels.net_repairs")}
value={Dinero(job.job_totals.totals.net_repairs).toFormat()}
/>
</Space>
{/* Validation */}
<Form.Item shouldUpdate>
{() => {
const advisorOk = !!form.getFieldValue("advisorNo");
return (
<Space size="large" wrap align="center">
<Statistic title={t("jobs.labels.subtotal")} value={totals.totalSale.toFormat()} />
<Typography.Title>=</Typography.Title>
<Button disabled={!advisorOk} htmlType="submit">
{t("jobs.actions.dms.post")}
</Button>
</Space>
);
}}
</Form.Item>
</Form>
</Card>
);
}

View File

@@ -55,8 +55,7 @@ const Eula = ({ currentEula, currentUser, acceptEula }) => {
const useremail = currentUser.email; const useremail = currentUser.email;
try { try {
// eslint-disable-next-line no-unused-vars const { ...otherFormValues } = formValues;
const { accepted_terms, ...otherFormValues } = formValues;
// Trim the values of the fields before submitting // Trim the values of the fields before submitting
const trimmedFormValues = Object.entries(otherFormValues).reduce((acc, [key, value]) => { const trimmedFormValues = Object.entries(otherFormValues).reduce((acc, [key, value]) => {

View File

@@ -141,7 +141,7 @@ const buildAccountingChildren = ({
</Link> </Link>
) )
}, },
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) || DmsAp.treatment === "on" ...(!(bodyshop?.cdk_dealerid || bodyshop?.pbs_serialnumber || bodyshop?.rr_dealerid) || DmsAp.treatment === "on"
? [ ? [
{ {
key: "payables", key: "payables",
@@ -156,7 +156,7 @@ const buildAccountingChildren = ({
} }
] ]
: []), : []),
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) ...(!(bodyshop?.cdk_dealerid || bodyshop?.pbs_serialnumber || bodyshop?.rr_dealerid) || DmsAp.treatment === "on"
? [ ? [
{ {
key: "payments", key: "payments",

View File

@@ -222,37 +222,17 @@ export function JobLifecycleComponent({ bodyshop, job, statuses }) {
</div> </div>
</BlurWrapperComponent> </BlurWrapperComponent>
<Card type="inner" title={t("job_lifecycle.content.legend_title")} style={{ marginTop: "10px" }}> <Card type="inner" title={t("job_lifecycle.content.legend_title")} style={{ marginTop: "10px" }}>
<div <div>
style={{
display: "flex",
flexWrap: "wrap",
gap: 8
}}
>
{lifecycleData.durations.summations.map((key) => ( {lifecycleData.durations.summations.map((key) => (
<Tag <Tag key={key.status} color={key.color} style={{ width: "13vh", padding: "4px", margin: "4px" }}>
key={key.status}
color={key.color}
style={{
// let the tag grow with its content
width: "auto",
padding: 0,
margin: 0,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
boxSizing: "border-box"
}}
>
<div <div
aria-label={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`} aria-label={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
title={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`} title={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
style={{ style={{
backgroundColor: "var(--tag-wrapper-bg)", backgroundColor: "var(--tag-wrapper-bg)",
color: "var(--tag-wrapper-text)", color: "var(--tag-wrapper-text)",
padding: "4px 8px", padding: "4px",
textAlign: "center", textAlign: "center"
whiteSpace: "nowrap" // single line; tag gets wider instead of text escaping
}} }}
> >
{key.status} ( {key.status} (

View File

@@ -6,6 +6,7 @@ import { createStructuredSelector } from "reselect";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr"; import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
@@ -14,6 +15,8 @@ const mapStateToProps = createStructuredSelector({
export function JobsCloseAutoAllocate({ bodyshop, joblines, form, disabled }) { export function JobsCloseAutoAllocate({ bodyshop, joblines, form, disabled }) {
const { t } = useTranslation(); const { t } = useTranslation();
const hasDmsKey = bodyshopHasDmsKey(bodyshop);
const handleAllocate = (defaults) => { const handleAllocate = (defaults) => {
form.setFieldsValue({ form.setFieldsValue({
joblines: joblines.map((jl) => { joblines: joblines.map((jl) => {
@@ -64,21 +67,20 @@ export function JobsCloseAutoAllocate({ bodyshop, joblines, form, disabled }) {
handleAllocate(bodyshop.md_responsibility_centers.dms_defaults.find((x) => x.name === key)); handleAllocate(bodyshop.md_responsibility_centers.dms_defaults.find((x) => x.name === key));
}; };
const menu = const menu = hasDmsKey
bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber ? {
? { items: bodyshop.md_responsibility_centers.dms_defaults.map((mapping) => ({
items: bodyshop.md_responsibility_centers.dms_defaults.map((mapping) => ({ key: mapping.name,
key: mapping.name, label: mapping.name,
label: mapping.name, disabled: disabled
disabled: disabled })),
})), onClick: handleMenuClick
onClick: handleMenuClick }
} : {
: { items: []
items: [] };
};
return bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber ? ( return hasDmsKey ? (
<Dropdown menu={menu}> <Dropdown menu={menu}>
<Button disabled={disabled}>{t("jobs.actions.dmsautoallocate")}</Button> <Button disabled={disabled}>{t("jobs.actions.dmsautoallocate")}</Button>
</Dropdown> </Dropdown>

View File

@@ -10,6 +10,7 @@ import { auth, logImEXEvent } from "../../firebase/firebase.utils";
import { INSERT_EXPORT_LOG } from "../../graphql/accounting.queries"; import { INSERT_EXPORT_LOG } from "../../graphql/accounting.queries";
import { UPDATE_JOB } from "../../graphql/jobs.queries"; import { UPDATE_JOB } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions"; import { insertAuditTrail } from "../../redux/application/application.actions";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings"; import AuditTrailMapping from "../../utils/AuditTrailMappings";
import client from "../../utils/GraphQLClient"; import client from "../../utils/GraphQLClient";
@@ -45,7 +46,7 @@ export function JobsCloseExportButton({ bodyshop, currentUser, jobId, disabled,
const handleQbxml = async () => { const handleQbxml = async () => {
//Check if it's a CDK setup. //Check if it's a CDK setup.
if (bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber) { if (bodyshopHasDmsKey(bodyshop)) {
history(`/manage/dms?jobId=${jobId}`); history(`/manage/dms?jobId=${jobId}`);
return; return;
} }

View File

@@ -609,7 +609,7 @@ export function JobsDetailHeaderActions({
<FormDateTimePickerComponent <FormDateTimePickerComponent
onBlur={() => { onBlur={() => {
const start = form.getFieldValue("start"); const start = form.getFieldValue("start");
form.setFieldsValue({ end: start?.add(30, "minutes") }); form.setFieldsValue({ end: start.add(30, "minutes") });
}} }}
/> />
</Form.Item> </Form.Item>

View File

@@ -29,6 +29,7 @@ import ProductionListColumnProductionNote from "../production-list-columns/produ
import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component"; import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component";
import "./jobs-detail-header.styles.scss"; import "./jobs-detail-header.styles.scss";
import getPartsBasePath from "../../utils/getPartsBasePath.js"; import getPartsBasePath from "../../utils/getPartsBasePath.js";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly, jobRO: selectJobReadOnly,
@@ -309,7 +310,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
</DataLabel> </DataLabel>
<DataLabel key="4" label={t("vehicles.fields.v_vin")}> <DataLabel key="4" label={t("vehicles.fields.v_vin")}>
<VehicleVinDisplay>{`${job.v_vin || t("general.labels.na")}`}</VehicleVinDisplay> <VehicleVinDisplay>{`${job.v_vin || t("general.labels.na")}`}</VehicleVinDisplay>
{bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid ? ( {bodyshopHasDmsKey(bodyshop) ? (
job.v_vin?.length !== 17 ? ( job.v_vin?.length !== 17 ? (
<WarningFilled style={{ color: "tomato", marginLeft: ".3rem" }} /> <WarningFilled style={{ color: "tomato", marginLeft: ".3rem" }} />
) : null ) : null

View File

@@ -35,14 +35,16 @@ export function JobsDocumentsImgproxyDownloadButton({ galleryImages, identifier,
...galleryImages.other.filter((image) => image.isSelected) ...galleryImages.other.filter((image) => image.isSelected)
]; ];
const downloadProgress = ({ loaded }) => { function downloadProgress(progressEvent) {
setDownload((currentDownloadState) => ({ setDownload((currentDownloadState) => {
downloaded: loaded ?? 0, return {
speed: (loaded ?? 0) - (currentDownloadState?.downloaded ?? 0) downloaded: progressEvent.loaded || 0,
})); speed: (progressEvent.loaded || 0) - ((currentDownloadState && currentDownloadState.downloaded) || 0)
}; };
});
}
const standardMediaDownload = (bufferData) => { function standardMediaDownload(bufferData) {
try { try {
const a = document.createElement("a"); const a = document.createElement("a");
const url = window.URL.createObjectURL(new Blob([bufferData])); const url = window.URL.createObjectURL(new Blob([bufferData]));
@@ -53,26 +55,29 @@ export function JobsDocumentsImgproxyDownloadButton({ galleryImages, identifier,
setLoading(false); setLoading(false);
setDownload(null); setDownload(null);
} }
}; }
const handleDownload = async () => { const handleDownload = async () => {
logImEXEvent("jobs_documents_download"); logImEXEvent("jobs_documents_download");
setLoading(true); setLoading(true);
try { try {
const { data } = await axios({ const response = await axios({
url: "/media/imgproxy/download", url: "/media/imgproxy/download",
method: "POST", method: "POST",
responseType: "blob", responseType: "blob",
data: { jobId, documentids: imagesToDownload.map((_) => _.id) }, data: { jobId, documentids: imagesToDownload.map((_) => _.id) },
onDownloadProgress: downloadProgress onDownloadProgress: downloadProgress
}); });
// Use the response data (Blob) to trigger download
standardMediaDownload(data);
} catch {
// handle error (optional)
} finally {
setLoading(false); setLoading(false);
setDownload(null); setDownload(null);
// Use the response data (Blob) to trigger download
standardMediaDownload(response.data);
} catch {
setLoading(false);
setDownload(null);
// handle error (optional)
} }
}; };

View File

@@ -76,14 +76,14 @@ function JobsDocumentsImgproxyComponent({
<SyncOutlined /> <SyncOutlined />
</Button> </Button>
<JobsDocumentsGallerySelectAllComponent galleryImages={galleryImages} setGalleryImages={setGalleryImages} /> <JobsDocumentsGallerySelectAllComponent galleryImages={galleryImages} setGalleryImages={setGalleryImages} />
{!billId && (
<JobsDocumentsGalleryReassign galleryImages={galleryImages} callback={fetchThumbnails || refetch} />
)}
<JobsDocumentsDownloadButton galleryImages={galleryImages} identifier={downloadIdentifier} jobId={jobId} /> <JobsDocumentsDownloadButton galleryImages={galleryImages} identifier={downloadIdentifier} jobId={jobId} />
<JobsDocumentsDeleteButton <JobsDocumentsDeleteButton
galleryImages={galleryImages} galleryImages={galleryImages}
deletionCallback={billsCallback || fetchThumbnails || refetch} deletionCallback={billsCallback || fetchThumbnails || refetch}
/> />
{!billId && (
<JobsDocumentsGalleryReassign galleryImages={galleryImages} callback={fetchThumbnails || refetch} />
)}
</Space> </Space>
</Col> </Col>
{!hasMediaAccess && ( {!hasMediaAccess && (

View File

@@ -67,7 +67,7 @@ export default function JobsDocumentsImgproxyDeleteButton({ galleryImages, delet
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
cancelText={t("general.actions.cancel")} cancelText={t("general.actions.cancel")}
> >
<Button danger disabled={imagesToDelete.length < 1} loading={loading}> <Button disabled={imagesToDelete.length < 1} loading={loading}>
{t("documents.actions.delete")} {t("documents.actions.delete")}
</Button> </Button>
</Popconfirm> </Popconfirm>

View File

@@ -107,8 +107,8 @@ export function JobsDocumentsLocalGallery({
<a href={CreateExplorerLinkForJob({ jobid: job.id })}> <a href={CreateExplorerLinkForJob({ jobid: job.id })}>
<Button>{t("documents.labels.openinexplorer")}</Button> <Button>{t("documents.labels.openinexplorer")}</Button>
</a> </a>
<JobsDocumentsLocalGallerySelectAllComponent jobid={job.id} />
<JobsDocumentsLocalGalleryReassign jobid={job.id} /> <JobsDocumentsLocalGalleryReassign jobid={job.id} />
<JobsDocumentsLocalGallerySelectAllComponent jobid={job.id} />
<JobsLocalGalleryDownloadButton job={job} /> <JobsLocalGalleryDownloadButton job={job} />
<JobsDocumentsLocalDeleteButton jobid={job.id} /> <JobsDocumentsLocalDeleteButton jobid={job.id} />
</Space> </Space>

View File

@@ -28,8 +28,6 @@ export function JobsDocumentsLocalDeleteButton({ bodyshop, getJobMedia, allMedia
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const imagesToDelete = (allMedia?.[jobid] || []).filter((i) => i.isSelected);
const handleDelete = async () => { const handleDelete = async () => {
logImEXEvent("job_documents_delete"); logImEXEvent("job_documents_delete");
setLoading(true); setLoading(true);
@@ -38,7 +36,7 @@ export function JobsDocumentsLocalDeleteButton({ bodyshop, getJobMedia, allMedia
`${bodyshop.localmediaserverhttp}/jobs/delete`, `${bodyshop.localmediaserverhttp}/jobs/delete`,
{ {
jobid: jobid, jobid: jobid,
files: imagesToDelete.map((i) => i.filename) files: (allMedia?.[jobid] || []).filter((i) => i.isSelected).map((i) => i.filename)
}, },
{ headers: { ims_token: bodyshop.localmediatoken } } { headers: { ims_token: bodyshop.localmediatoken } }
); );
@@ -62,17 +60,14 @@ export function JobsDocumentsLocalDeleteButton({ bodyshop, getJobMedia, allMedia
return ( return (
<Popconfirm <Popconfirm
disabled={imagesToDelete.length < 1}
icon={<QuestionCircleOutlined style={{ color: "red" }} />} icon={<QuestionCircleOutlined style={{ color: "red" }} />}
onConfirm={handleDelete} onConfirm={handleDelete}
title={t("documents.labels.confirmdelete")} title={t("documents.labels.confirmdelete")}
okText={t("general.actions.delete")} okText={t("general.actions.delete")}
okButtonProps={{ danger: true }} okButtonProps={{ type: "danger" }}
cancelText={t("general.actions.cancel")} cancelText={t("general.actions.cancel")}
> >
<Button danger disabled={imagesToDelete.length < 1} loading={loading}> <Button loading={loading}>{t("documents.actions.delete")}</Button>
{t("documents.actions.delete")}
</Button>
</Popconfirm> </Popconfirm>
); );
} }

View File

@@ -1,8 +1,8 @@
import { Button, Space } from "antd"; import { Button } from "antd";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import cleanAxios from "../../utils/CleanAxios"; import cleanAxios from "../../utils/CleanAxios";
import formatBytes from "../../utils/formatbytes";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectAllMedia } from "../../redux/media/media.selectors"; import { selectAllMedia } from "../../redux/media/media.selectors";
@@ -19,63 +19,45 @@ export default connect(mapStateToProps, mapDispatchToProps)(JobsLocalGalleryDown
export function JobsLocalGalleryDownloadButton({ bodyshop, allMedia, job }) { export function JobsLocalGalleryDownloadButton({ bodyshop, allMedia, job }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [download, setDownload] = useState(null);
const [download, setDownload] = useState(false);
const imagesToDownload = (allMedia?.[job.id] || []).filter((i) => i.isSelected); function downloadProgress(progressEvent) {
setDownload((currentDownloadState) => {
const downloadProgress = ({ loaded }) => { return {
setDownload((currentDownloadState) => ({ downloaded: progressEvent.loaded || 0,
downloaded: loaded || 0, speed: (progressEvent.loaded || 0) - (currentDownloadState?.downloaded || 0)
speed: (loaded || 0) - (currentDownloadState?.downloaded || 0) };
})); });
}; }
const standardMediaDownload = (bufferData, filename) => {
try {
const a = document.createElement("a");
const url = window.URL.createObjectURL(new Blob([bufferData]));
a.href = url;
a.download = `${filename}.zip`;
a.click();
} catch {
setLoading(false);
setDownload(null);
}
};
const handleDownload = async () => { const handleDownload = async () => {
const { localmediaserverhttp, localmediatoken } = bodyshop; const theDownloadedZip = await cleanAxios.post(
const { id, ro_number } = job; `${bodyshop.localmediaserverhttp}/jobs/download`,
setLoading(true); {
try { jobid: job.id,
const response = await cleanAxios.post( files: (allMedia?.[job.id] || []).filter((i) => i.isSelected).map((i) => i.filename)
`${localmediaserverhttp}/jobs/download`, },
{ {
jobid: id, headers: { ims_token: bodyshop.localmediatoken },
files: imagesToDownload.map((i) => i.filename) responseType: "arraybuffer",
}, onDownloadProgress: downloadProgress
{ }
headers: { ims_token: localmediatoken }, );
responseType: "arraybuffer", setDownload(null);
onDownloadProgress: downloadProgress standardMediaDownload(theDownloadedZip.data, job.ro_number);
}
);
standardMediaDownload(response.data, ro_number);
} catch {
// handle error (optional)
} finally {
setLoading(false);
setDownload(null);
}
}; };
return ( return (
<Button disabled={imagesToDownload < 1} loading={download || loading} onClick={handleDownload}> <Button loading={!!download} onClick={handleDownload}>
<Space> {t("documents.actions.download")}
<span>{t("documents.actions.download")}</span>
{download && <span>{`(${formatBytes(download.downloaded)} @ ${formatBytes(download.speed)} / second)`}</span>}
</Space>
</Button> </Button>
); );
} }
function standardMediaDownload(bufferData, filename) {
const a = document.createElement("a");
const url = window.URL.createObjectURL(new Blob([bufferData]));
a.href = url;
a.download = `${filename}.zip`;
a.click();
}

View File

@@ -1,4 +1,5 @@
import i18next from "i18next"; import i18next from "i18next";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
export const CalculateAllocationsTotals = (bodyshop, joblines, timetickets, adjustments = []) => { export const CalculateAllocationsTotals = (bodyshop, joblines, timetickets, adjustments = []) => {
const responsibilitycenters = bodyshop.md_responsibility_centers; const responsibilitycenters = bodyshop.md_responsibility_centers;
@@ -14,10 +15,9 @@ export const CalculateAllocationsTotals = (bodyshop, joblines, timetickets, adju
const r = allCodes.reduce((acc, value) => { const r = allCodes.reduce((acc, value) => {
const r = { const r = {
opcode: value, opcode: value,
cost_center: cost_center: bodyshopHasDmsKey(bodyshop)
bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber ? i18next.t(`joblines.fields.lbr_types.${value && value.toUpperCase()}`)
? i18next.t(`joblines.fields.lbr_types.${value && value.toUpperCase()}`) : responsibilitycenters.defaults.costs[value],
: responsibilitycenters.defaults.costs[value],
mod_lbr_ty: value, mod_lbr_ty: value,
total: joblines.reduce((acc2, val2) => { total: joblines.reduce((acc2, val2) => {
return val2.mod_lbr_ty === value ? acc2 + val2.mod_lb_hrs : acc2; return val2.mod_lbr_ty === value ? acc2 + val2.mod_lb_hrs : acc2;

View File

@@ -44,6 +44,7 @@ export async function checkPartnerStatus(bodyshop) {
// bodyshop && // bodyshop &&
// (bodyshop.cdk_dealerid || // (bodyshop.cdk_dealerid ||
// bodyshop.pbs_serialnumber || // bodyshop.pbs_serialnumber ||
// bodyshop.rr_dealerid ||
// bodyshop.accountingconfig.qbo) // bodyshop.accountingconfig.qbo)
// ) // )
// ) { // ) {

View File

@@ -28,6 +28,7 @@ import PartsOrderDeleteLine from "../parts-order-delete-line/parts-order-delete-
import PartsOrderLineBackorderButton from "../parts-order-line-backorder-button/parts-order-line-backorder-button.component"; import PartsOrderLineBackorderButton from "../parts-order-line-backorder-button/parts-order-line-backorder-button.component";
import PartsReceiveModalContainer from "../parts-receive-modal/parts-receive-modal.container"; import PartsReceiveModalContainer from "../parts-receive-modal/parts-receive-modal.container";
import PrintWrapper from "../print-wrapper/print-wrapper.component"; import PrintWrapper from "../print-wrapper/print-wrapper.component";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly, jobRO: selectJobReadOnly,
@@ -196,7 +197,7 @@ export function PartsOrderListTableDrawerComponent({
quantity: pol.quantity, quantity: pol.quantity,
actual_price: pol.act_price, actual_price: pol.act_price,
cost_center: pol.jobline?.part_type cost_center: pol.jobline?.part_type
? bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid ? bodyshopHasDmsKey(bodyshop)
? pol.jobline.part_type !== "PAE" ? pol.jobline.part_type !== "PAE"
? pol.jobline.part_type ? pol.jobline.part_type
: null : null

View File

@@ -20,6 +20,7 @@ import PartsReceiveModalContainer from "../parts-receive-modal/parts-receive-mod
import PrintWrapper from "../print-wrapper/print-wrapper.component"; import PrintWrapper from "../print-wrapper/print-wrapper.component";
import PartsOrderDrawer from "./parts-order-list-table-drawer.component"; import PartsOrderDrawer from "./parts-order-list-table-drawer.component";
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx"; import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly, jobRO: selectJobReadOnly,
@@ -69,6 +70,7 @@ export function PartsOrderListTableComponent({
const parts_orders = billsQuery.data ? billsQuery.data.parts_orders : []; const parts_orders = billsQuery.data ? billsQuery.data.parts_orders : [];
const { refetch } = billsQuery; const { refetch } = billsQuery;
const recordActions = (record, showView = false) => ( const recordActions = (record, showView = false) => (
<Space direction="horizontal" wrap> <Space direction="horizontal" wrap>
<ShareToTeamsButton <ShareToTeamsButton
@@ -172,7 +174,7 @@ export function PartsOrderListTableComponent({
actual_price: pol.act_price, actual_price: pol.act_price,
cost_center: pol.jobline?.part_type cost_center: pol.jobline?.part_type
? bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid ? bodyshopHasDmsKey(bodyshop)
? pol.jobline.part_type !== "PAE" ? pol.jobline.part_type !== "PAE"
? pol.jobline.part_type ? pol.jobline.part_type
: null : null

View File

@@ -144,7 +144,7 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
<Spin spinning={loading}> <Spin spinning={loading}>
{record[type] ? ( {record[type] ? (
<div> <div>
<span>{`${theEmployee?.first_name || ""} ${theEmployee?.last_name || ""}`}</span> <span>{`${theEmployee.first_name || ""} ${theEmployee.last_name || ""}`}</span>
<DeleteFilled style={iconStyle} onClick={() => handleRemove(type)} /> <DeleteFilled style={iconStyle} onClick={() => handleRemove(type)} />
</div> </div>
) : ( ) : (

View File

@@ -143,7 +143,7 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
//TODO: Find a way to filter out / blur on demand. //TODO: Find a way to filter out / blur on demand.
return ( return (
<div className="report-center-modal"> <div>
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}> <Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
<Input.Search onChange={(e) => setSearch(e.target.value)} value={search} /> <Input.Search onChange={(e) => setSearch(e.target.value)} value={search} />
<Form.Item name="defaultSorters" hidden /> <Form.Item name="defaultSorters" hidden />
@@ -163,14 +163,13 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
{Object.keys(grouped) {Object.keys(grouped)
//.filter((key) => !groupExcludeKeyFilter.includes(key)) //.filter((key) => !groupExcludeKeyFilter.includes(key))
.map((key) => ( .map((key) => (
<Col xs={24} sm={12} md={Object.keys(grouped).length === 1 ? 24 : 8} key={key}> <Col md={8} sm={12} key={key}>
<Card.Grid <Card.Grid
style={{ style={{
width: "100%", width: "100%",
height: "100%", height: "100%",
maxHeight: "33vh", maxHeight: "33vh",
overflowY: "scroll", overflowY: "scroll"
minWidth: "200px"
}} }}
> >
<Typography.Title level={4}>{t(`reportcenter.labels.groups.${key}`)}</Typography.Title> <Typography.Title level={4}>{t(`reportcenter.labels.groups.${key}`)}</Typography.Title>
@@ -178,7 +177,7 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
<BlurWrapperComponent <BlurWrapperComponent
featureName={groupExcludeKeyFilter.find((g) => g.key === key).featureName} featureName={groupExcludeKeyFilter.find((g) => g.key === key).featureName}
> >
<ul style={{ listStyleType: "none", columns: grouped[key].length > 4 ? "2 auto" : "1", padding: 0, margin: 0 }}> <ul style={{ listStyleType: "none", columns: "2 auto" }}>
{grouped[key].map((item) => ( {grouped[key].map((item) => (
<li key={item.key}> <li key={item.key}>
<Radio key={item.key} value={item.key}> <Radio key={item.key} value={item.key}>
@@ -189,7 +188,7 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
</ul> </ul>
</BlurWrapperComponent> </BlurWrapperComponent>
) : ( ) : (
<ul style={{ listStyleType: "none", columns: grouped[key].length > 4 ? "2 auto" : "1", padding: 0, margin: 0 }}> <ul style={{ listStyleType: "none", columns: "2 auto" }}>
{grouped[key].map((item) => {grouped[key].map((item) =>
item.featureNameRestricted ? ( item.featureNameRestricted ? (
<li key={item.key}> <li key={item.key}>

View File

@@ -11,38 +11,3 @@
} }
} }
} }
// Report center modal fixes for column layout
.report-center-modal {
.ant-form-item .ant-radio-group {
width: 100%;
.ant-card-grid {
padding: 16px;
box-sizing: border-box;
ul {
width: 100%;
li {
margin-bottom: 8px;
break-inside: avoid;
page-break-inside: avoid;
.ant-radio-wrapper {
display: flex;
align-items: flex-start;
width: 100%;
span:not(.ant-radio) {
word-break: break-word;
overflow-wrap: break-word;
hyphens: auto;
flex: 1;
}
}
}
}
}
}
}

View File

@@ -331,7 +331,10 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
{t("timetickets.labels.shift")} {t("timetickets.labels.shift")}
</Select.Option> </Select.Option>
{bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || Enhanced_Payroll.treatment === "on" {bodyshop.cdk_dealerid ||
bodyshop.pbs_serialnumber ||
bodyshop.rr_dealerid ||
Enhanced_Payroll.treatment === "on"
? CiecaSelect(false, true) ? CiecaSelect(false, true)
: bodyshop.md_responsibility_centers.costs.map((c) => ( : bodyshop.md_responsibility_centers.costs.map((c) => (
<Select.Option key={c.name} value={c.name}> <Select.Option key={c.name} value={c.name}>

View File

@@ -16,7 +16,6 @@ export default function ShopInfoNotificationsAutoadd({ bodyshop }) {
<Text type="secondary">{t("bodyshop.labels.notifications.followers")}</Text> <Text type="secondary">{t("bodyshop.labels.notifications.followers")}</Text>
{employeeOptions.length > 0 ? ( {employeeOptions.length > 0 ? (
<Form.Item <Form.Item
normalize={(value) => (value || []).filter((id) => typeof id === "string" && id.trim() !== "")}
name="notification_followers" name="notification_followers"
rules={[ rules={[
{ {
@@ -43,6 +42,11 @@ export default function ShopInfoNotificationsAutoadd({ bodyshop }) {
options={employeeOptions} options={employeeOptions}
placeholder={t("bodyshop.fields.notifications.placeholder")} placeholder={t("bodyshop.fields.notifications.placeholder")}
showEmail={true} showEmail={true}
onChange={(value) => {
// Filter out null or invalid values before passing to Form
const cleanedValue = value?.filter((id) => id != null && typeof id === "string" && id.trim() !== "");
return cleanedValue;
}}
/> />
</Form.Item> </Form.Item>
) : ( ) : (

View File

@@ -5,6 +5,7 @@ import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr"; import InstanceRenderManager from "../../utils/instanceRenderMgr";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser //currentUser: selectCurrentUser
@@ -2126,7 +2127,7 @@ function TaxFormItems({ typeNum, typeNumIterator, rootElements, bodyshop }) {
> >
<Input /> <Input />
</Form.Item> </Form.Item>
{(bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber) && ( {bodyshopHasDmsKey(bodyshop) && (
<Form.Item <Form.Item
label={t("bodyshop.fields.dms.dms_acctnumber")} label={t("bodyshop.fields.dms.dms_acctnumber")}
rules={[ rules={[

View File

@@ -16,7 +16,7 @@ const mapDispatchToProps = () => ({
export function TechHeader({ technician }) { export function TechHeader({ technician }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Header style={{ textAlign: "center", height: "auto", overflow: "visible" }}> <Header style={{ textAlign: "center" }}>
<Typography.Title style={{ color: "#fff" }}> <Typography.Title style={{ color: "#fff" }}>
{technician {technician
? t("tech.labels.loggedin", { ? t("tech.labels.loggedin", {

View File

@@ -63,7 +63,10 @@ export function TechClockInComponent({ form, bodyshop, technician }) {
<Select.Option key={item.cost_center} value={item.cost_center}> <Select.Option key={item.cost_center} value={item.cost_center}>
{item.cost_center === "timetickets.labels.shift" {item.cost_center === "timetickets.labels.shift"
? t(item.cost_center) ? t(item.cost_center)
: bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || Enhanced_Payroll.treatment === "on" : bodyshop.cdk_dealerid ||
bodyshop.pbs_serialnumber ||
bodyshop.rr_dealerid ||
Enhanced_Payroll.treatment === "on"
? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`) ? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`)
: item.cost_center} : item.cost_center}
</Select.Option> </Select.Option>

View File

@@ -75,7 +75,10 @@ export function TechClockInContainer({ setTimeTicketContext, technician, bodysho
jobid: values.jobid, jobid: values.jobid,
cost_center: values.cost_center, cost_center: values.cost_center,
ciecacode: ciecacode:
bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || Enhanced_Payroll.treatment === "on" bodyshop.cdk_dealerid ||
bodyshop.pbs_serialnumber ||
bodyshop.rr_dealerid ||
Enhanced_Payroll.treatment === "on"
? values.cost_center ? values.cost_center
: Object.keys(bodyshop.md_responsibility_centers.defaults.costs).find((key) => { : Object.keys(bodyshop.md_responsibility_centers.defaults.costs).find((key) => {
return bodyshop.md_responsibility_centers.defaults.costs[key] === values.cost_center; return bodyshop.md_responsibility_centers.defaults.costs[key] === values.cost_center;

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery } from "@apollo/client"; import { useMutation, useQuery } from "@apollo/client";
import { Button, Card, Form, InputNumber, Popover, Select, Space } from "antd"; import { Button, Card, Col, Form, InputNumber, Popover, Row, Select } from "antd";
import axios from "axios"; import axios from "axios";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -16,6 +16,7 @@ import TechJobClockoutDelete from "../tech-job-clock-out-delete/tech-job-clock-o
import { LaborAllocationContainer } from "../time-ticket-modal/time-ticket-modal.component"; import { LaborAllocationContainer } from "../time-ticket-modal/time-ticket-modal.component";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -53,7 +54,9 @@ export function TechClockOffButton({
nextFetchPolicy: "network-only" nextFetchPolicy: "network-only"
}); });
const { t } = useTranslation(); const { t } = useTranslation();
const emps = bodyshop.employees.filter((e) => e.id === (technician && technician.id))[0];
const emps = bodyshop.employees.filter((e) => e.id === technician?.id)[0];
const hasDmsKey = bodyshopHasDmsKey(bodyshop);
const handleFinish = async (values) => { const handleFinish = async (values) => {
logImEXEvent("tech_clock_out_job"); logImEXEvent("tech_clock_out_job");
@@ -69,7 +72,10 @@ export function TechClockOffButton({
rate: emps && emps.rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate, rate: emps && emps.rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate,
flat_rate: emps && emps.flat_rate, flat_rate: emps && emps.flat_rate,
ciecacode: ciecacode:
bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || Enhanced_Payroll.treatment === "on" bodyshop.cdk_dealerid ||
bodyshop.pbs_serialnumber ||
bodyshop.rr_dealerid ||
Enhanced_Payroll.treatment === "on"
? values.cost_center ? values.cost_center
: Object.keys(bodyshop.md_responsibility_centers.defaults.costs).find((key) => { : Object.keys(bodyshop.md_responsibility_centers.defaults.costs).find((key) => {
return bodyshop.md_responsibility_centers.defaults.costs[key] === values.cost_center; return bodyshop.md_responsibility_centers.defaults.costs[key] === values.cost_center;
@@ -124,97 +130,70 @@ export function TechClockOffButton({
cost_center: isShiftTicket ? "timetickets.labels.shift" : technician ? technician.cost_center : null cost_center: isShiftTicket ? "timetickets.labels.shift" : technician ? technician.cost_center : null
}} }}
> >
<Space direction="vertical"> <Row gutter={[16, 16]}>
{!isShiftTicket ? ( <Col span={!isShiftTicket ? 8 : 24}>
<div> {!isShiftTicket ? (
<Form.Item <div>
label={t("timetickets.fields.actualhrs")} <Form.Item
name="actualhrs" label={t("timetickets.fields.actualhrs")}
rules={[ name="actualhrs"
{ rules={[
required: true {
//message: t("general.validation.required"), required: true
} //message: t("general.validation.required"),
]}
>
<InputNumber min={0} precision={1} />
</Form.Item>
<Form.Item
label={t("timetickets.fields.productivehrs")}
name="productivehrs"
rules={[
{
required: true
//message: t("general.validation.required"),
},
({ getFieldValue }) => ({
validator(rule, value) {
if (!bodyshop.tt_enforce_hours_for_tech_console) {
return Promise.resolve();
}
if (!value || getFieldValue("cost_center") === null || !lineTicketData)
return Promise.resolve();
//Check the cost center,
const totals = CalculateAllocationsTotals(
bodyshop,
lineTicketData.joblines,
lineTicketData.timetickets,
lineTicketData.jobs_by_pk.lbr_adjustments
);
const fieldTypeToCheck =
bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber ? "mod_lbr_ty" : "cost_center";
const costCenterDiff =
Math.round(
totals.find((total) => total[fieldTypeToCheck] === getFieldValue("cost_center"))
?.difference * 10
) / 10;
if (value > costCenterDiff)
return Promise.reject(t("timetickets.validation.hoursenteredmorethanavailable"));
else {
return Promise.resolve();
}
} }
}) ]}
]} >
> <InputNumber min={0} precision={1} />
<InputNumber min={0} precision={1} /> </Form.Item>
</Form.Item> <Form.Item
</div> label={t("timetickets.fields.productivehrs")}
) : null} name="productivehrs"
<Form.Item rules={[
name="cost_center" {
label={t("timetickets.fields.cost_center")} required: true
rules={[ //message: t("general.validation.required"),
{ },
required: true ({ getFieldValue }) => ({
//message: t("general.validation.required"), validator(rule, value) {
} if (!bodyshop.tt_enforce_hours_for_tech_console) {
]} return Promise.resolve();
> }
<Select disabled={isShiftTicket}> if (!value || getFieldValue("cost_center") === null || !lineTicketData)
{isShiftTicket ? ( return Promise.resolve();
<Select.Option value="timetickets.labels.shift">{t("timetickets.labels.shift")}</Select.Option>
) : ( //Check the cost center,
emps && const totals = CalculateAllocationsTotals(
emps.rates.map((item) => ( bodyshop,
<Select.Option key={item.cost_center}> lineTicketData.joblines,
{item.cost_center === "timetickets.labels.shift" lineTicketData.timetickets,
? t(item.cost_center) lineTicketData.jobs_by_pk.lbr_adjustments
: bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber );
? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`)
: item.cost_center} const fieldTypeToCheck = hasDmsKey ? "mod_lbr_ty" : "cost_center";
</Select.Option>
)) const costCenterDiff =
)} Math.round(
</Select> totals.find((total) => total[fieldTypeToCheck] === getFieldValue("cost_center"))
</Form.Item> ?.difference * 10
{isShiftTicket ? ( ) / 10;
<div></div>
) : ( if (value > costCenterDiff)
return Promise.reject(t("timetickets.validation.hoursenteredmorethanavailable"));
else {
return Promise.resolve();
}
}
})
]}
>
<InputNumber min={0} precision={1} />
</Form.Item>
</div>
) : null}
<Form.Item <Form.Item
name="status" name="cost_center"
label={t("jobs.fields.status")} label={t("timetickets.fields.cost_center")}
initialValue={lineTicketData && lineTicketData.jobs_by_pk.status}
rules={[ rules={[
{ {
required: true required: true
@@ -222,32 +201,67 @@ export function TechClockOffButton({
} }
]} ]}
> >
<Select> <Select disabled={isShiftTicket}>
{bodyshop.md_ro_statuses.production_statuses.map((item) => ( {isShiftTicket ? (
<Select.Option key={item}></Select.Option> <Select.Option value="timetickets.labels.shift">{t("timetickets.labels.shift")}</Select.Option>
))} ) : (
emps &&
emps.rates.map((item) => (
<Select.Option key={item.cost_center}>
{item.cost_center === "timetickets.labels.shift"
? t(item.cost_center)
: hasDmsKey
? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`)
: item.cost_center}
</Select.Option>
))
)}
</Select> </Select>
</Form.Item> </Form.Item>
)}
<Button type="primary" htmlType="submit" loading={loading}> {isShiftTicket ? (
{t("general.actions.save")} <div></div>
</Button> ) : (
<TechJobClockoutDelete completedCallback={completedCallback} timeTicketId={timeTicketId} /> <Form.Item
name="status"
label={t("jobs.fields.status")}
initialValue={lineTicketData && lineTicketData.jobs_by_pk.status}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Select>
{bodyshop.md_ro_statuses.production_statuses.map((item) => (
<Select.Option key={item}></Select.Option>
))}
</Select>
</Form.Item>
)}
<Button type="primary" htmlType="submit" loading={loading}>
{t("general.actions.save")}
</Button>
<TechJobClockoutDelete completedCallback={completedCallback} timeTicketId={timeTicketId} />
</Col>
{!isShiftTicket && ( {!isShiftTicket && (
<LaborAllocationContainer jobid={jobId || null} loading={queryLoading} lineTicketData={lineTicketData} /> <Col span={16}>
<LaborAllocationContainer
jobid={jobId || null}
loading={queryLoading}
lineTicketData={lineTicketData}
/>
</Col>
)} )}
</Space> </Row>
</Form> </Form>
</div> </div>
</Card> </Card>
); );
return ( return (
<Popover <Popover content={overlay} trigger="click">
content={<div style={{ maxHeight: "75vh", overflowY: "auto" }}>{overlay}</div>}
trigger="click"
getPopupContainer={() => document.querySelector('#time-ticket-modal')}
>
<Button loading={loading} {...otherBtnProps}> <Button loading={loading} {...otherBtnProps}>
{t("timetickets.actions.clockout")} {t("timetickets.actions.clockout")}
</Button> </Button>

View File

@@ -14,6 +14,7 @@ import {
import JobSearchSelect from "../job-search-select/job-search-select.component"; import JobSearchSelect from "../job-search-select/job-search-select.component";
import LaborAllocationsTable from "../labor-allocations-table/labor-allocations-table.component"; import LaborAllocationsTable from "../labor-allocations-table/labor-allocations-table.component";
import { CalculateAllocationsTotals } from "../labor-allocations-table/labor-allocations-table.utility"; import { CalculateAllocationsTotals } from "../labor-allocations-table/labor-allocations-table.utility";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component"; import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component"; import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component";
@@ -60,7 +61,10 @@ export function TimeTicketModalComponent({
<Select.Option key={item.cost_center} value={item.cost_center}> <Select.Option key={item.cost_center} value={item.cost_center}>
{item.cost_center === "timetickets.labels.shift" {item.cost_center === "timetickets.labels.shift"
? t(item.cost_center) ? t(item.cost_center)
: bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || Enhanced_Payroll.treatment === "on" : bodyshop.cdk_dealerid ||
bodyshop.pbs_serialnumber ||
bodyshop.rr_dealerid ||
Enhanced_Payroll.treatment === "on"
? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`) ? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`)
: item.cost_center} : item.cost_center}
</Select.Option> </Select.Option>
@@ -124,7 +128,7 @@ export function TimeTicketModalComponent({
onSelect={(value) => { onSelect={(value) => {
const emps = employeeAutoCompleteOptions && employeeAutoCompleteOptions.filter((e) => e.id === value)[0]; const emps = employeeAutoCompleteOptions && employeeAutoCompleteOptions.filter((e) => e.id === value)[0];
form.setFieldsValue({ flat_rate: emps && emps.flat_rate }); form.setFieldsValue({ flat_rate: emps?.flat_rate });
}} }}
/> />
</Form.Item> </Form.Item>
@@ -186,8 +190,7 @@ export function TimeTicketModalComponent({
lineTicketData.jobs_by_pk.lbr_adjustments lineTicketData.jobs_by_pk.lbr_adjustments
); );
const fieldTypeToCheck = const fieldTypeToCheck = bodyshopHasDmsKey(bodyshop) ? "mod_lbr_ty" : "cost_center";
bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber ? "mod_lbr_ty" : "cost_center";
const costCenterDiff = const costCenterDiff =
Math.round( Math.round(
@@ -261,7 +264,7 @@ export function TimeTicketModalComponent({
if (!value) return Promise.resolve(); if (!value) return Promise.resolve();
if (!clockon && value) return Promise.reject(t("timetickets.validation.clockoffwithoutclockon")); if (!clockon && value) return Promise.reject(t("timetickets.validation.clockoffwithoutclockon"));
// TODO - Verify this exists // TODO - Verify this exists
if (value && value.isSameOrAfter && !value.isSameOrAfter(clockon)) if (value?.isSameOrAfter && !value.isSameOrAfter(clockon))
return Promise.reject(t("timetickets.validation.clockoffmustbeafterclockon")); return Promise.reject(t("timetickets.validation.clockoffmustbeafterclockon"));
return Promise.resolve(); return Promise.resolve();

View File

@@ -137,7 +137,10 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
if (!!changedFields.cost_center && !!EmployeeAutoCompleteData) { if (!!changedFields.cost_center && !!EmployeeAutoCompleteData) {
form.setFieldsValue({ form.setFieldsValue({
ciecacode: ciecacode:
bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || Enhanced_Payroll.treatment === "on" bodyshop.cdk_dealerid ||
bodyshop.pbs_serialnumber ||
bodyshop.rr_dealerid ||
Enhanced_Payroll.treatment === "on"
? changedFields.cost_center ? changedFields.cost_center
: Object.keys(bodyshop.md_responsibility_centers.defaults.costs).find( : Object.keys(bodyshop.md_responsibility_centers.defaults.costs).find(
(key) => bodyshop.md_responsibility_centers.defaults.costs[key] === changedFields.cost_center (key) => bodyshop.md_responsibility_centers.defaults.costs[key] === changedFields.cost_center

View File

@@ -149,7 +149,7 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
useEffect(() => { useEffect(() => {
const initializeSocket = async (token) => { const initializeSocket = async (token) => {
if (!bodyshop || !bodyshop.id || socketRef.current) return; if (!bodyshop?.id || socketRef.current) return;
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : ""; const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
const socketInstance = SocketIO(endpoint, { const socketInstance = SocketIO(endpoint, {

View File

@@ -5,7 +5,7 @@ import { getFirestore } from "@firebase/firestore";
import { getMessaging, getToken, onMessage } from "@firebase/messaging"; import { getMessaging, getToken, onMessage } from "@firebase/messaging";
import { store } from "../redux/store"; import { store } from "../redux/store";
//import * as amplitude from '@amplitude/analytics-browser'; //import * as amplitude from '@amplitude/analytics-browser';
// import posthog from 'posthog-js' import posthog from 'posthog-js'
const config = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG); const config = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
initializeApp(config); initializeApp(config);
@@ -74,6 +74,7 @@ onMessage(messaging, (payload) => {
export const logImEXEvent = (eventName, additionalParams, stateProp = null) => { export const logImEXEvent = (eventName, additionalParams, stateProp = null) => {
try { try {
const state = stateProp || store.getState(); const state = stateProp || store.getState();
const eventParams = { const eventParams = {
@@ -98,7 +99,8 @@ export const logImEXEvent = (eventName, additionalParams, stateProp = null) => {
// ); // );
logEvent(analytics, eventName, eventParams); logEvent(analytics, eventName, eventParams);
//amplitude.track(eventName, eventParams); //amplitude.track(eventName, eventParams);
//posthog.capture(eventName, eventParams); posthog.capture(eventName, eventParams);
} finally { } finally {
//If it fails, just keep going. //If it fails, just keep going.
} }

View File

@@ -111,6 +111,7 @@ export const QUERY_BODYSHOP = gql`
jc_hourly_rates jc_hourly_rates
md_jobline_presets md_jobline_presets
cdk_dealerid cdk_dealerid
rr_dealerid
features features
attach_pdf_to_email attach_pdf_to_email
tt_allow_post_to_invoiced tt_allow_post_to_invoiced
@@ -123,6 +124,7 @@ export const QUERY_BODYSHOP = gql`
md_email_cc md_email_cc
timezone timezone
ss_configuration ss_configuration
rr_configuration
md_from_emails md_from_emails
last_name_first last_name_first
md_parts_order_comment md_parts_order_comment
@@ -245,6 +247,7 @@ export const UPDATE_SHOP = gql`
jc_hourly_rates jc_hourly_rates
md_jobline_presets md_jobline_presets
cdk_dealerid cdk_dealerid
rr_dealerid
attach_pdf_to_email attach_pdf_to_email
tt_allow_post_to_invoiced tt_allow_post_to_invoiced
cdk_configuration cdk_configuration
@@ -256,6 +259,7 @@ export const UPDATE_SHOP = gql`
md_email_cc md_email_cc
timezone timezone
ss_configuration ss_configuration
rr_configuration
md_from_emails md_from_emails
last_name_first last_name_first
md_parts_order_comment md_parts_order_comment

View File

@@ -2,27 +2,7 @@ import { gql } from "@apollo/client";
export const UNREAD_CONVERSATION_COUNT = gql` export const UNREAD_CONVERSATION_COUNT = gql`
query UNREAD_CONVERSATION_COUNT { query UNREAD_CONVERSATION_COUNT {
# How many conversations have at least one unread inbound, non-system message messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
conversations_aggregate(
where: {
archived: { _eq: false }
messages: { read: { _eq: false }, isoutbound: { _eq: false }, is_system: { _eq: false } }
}
) {
aggregate {
count
}
}
# How many unread inbound, non-system messages exist (excluding archived conversations)
messages_aggregate(
where: {
read: { _eq: false }
isoutbound: { _eq: false }
is_system: { _eq: false }
conversation: { archived: { _eq: false } }
}
) {
aggregate { aggregate {
count count
} }
@@ -39,7 +19,7 @@ export const CONVERSATION_LIST_QUERY = gql`
unreadcnt unreadcnt
archived archived
label label
messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false }, is_system: { _eq: false } }) { messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
aggregate { aggregate {
count count
} }
@@ -61,7 +41,6 @@ export const CONVERSATION_SUBSCRIPTION_BY_PK = gql`
subscription CONVERSATION_SUBSCRIPTION_BY_PK($conversationId: uuid!) { subscription CONVERSATION_SUBSCRIPTION_BY_PK($conversationId: uuid!) {
messages(order_by: { created_at: asc_nulls_first }, where: { conversationid: { _eq: $conversationId } }) { messages(order_by: { created_at: asc_nulls_first }, where: { conversationid: { _eq: $conversationId } }) {
id id
conversationid
status status
text text
is_system is_system
@@ -97,7 +76,6 @@ export const GET_CONVERSATION_DETAILS = gql`
} }
messages(order_by: { created_at: asc_nulls_first }) { messages(order_by: { created_at: asc_nulls_first }) {
id id
conversationid
status status
text text
is_system is_system
@@ -132,7 +110,7 @@ export const CONVERSATION_ID_BY_PHONE = gql`
ro_number ro_number
} }
} }
messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false }, is_system: { _eq: false } }) { messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
aggregate { aggregate {
count count
} }
@@ -161,7 +139,7 @@ export const CREATE_CONVERSATION = gql`
ro_number ro_number
} }
} }
messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false }, is_system: { _eq: false } }) { messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
aggregate { aggregate {
count count
} }

View File

@@ -714,7 +714,7 @@ export const GET_JOB_BY_PK = gql`
v_model_yr v_model_yr
v_model_desc v_model_desc
v_vin v_vin
notes(where:{pinned: {_eq: true}}, order_by: {updated_at: desc}) { notes(where: { pinned: { _eq: true } }, order_by: { updated_at: desc }) {
created_at created_at
created_by created_by
critical critical
@@ -1000,7 +1000,6 @@ export const QUERY_JOB_CARD_DETAILS = gql`
key key
type type
} }
} }
} }
`; `;
@@ -2172,6 +2171,8 @@ export const QUERY_JOB_EXPORT_DMS = gql`
ownr_fn ownr_fn
ownr_ln ownr_ln
ownr_co_nm ownr_co_nm
ownr_ph1
ownr_ph2
ins_co_nm ins_co_nm
kmin kmin
kmout kmout
@@ -2180,6 +2181,10 @@ export const QUERY_JOB_EXPORT_DMS = gql`
v_model_desc v_model_desc
area_of_damage area_of_damage
date_exported date_exported
v_vin
plate_no
plate_st
ownr_co_nm
} }
} }
`; `;
@@ -2415,7 +2420,7 @@ export const QUERY_PARTS_QUEUE_CARD_DETAILS = gql`
start start
status status
} }
notes(where:{pinned: {_eq: true}}, order_by: {updated_at: desc}) { notes(where: { pinned: { _eq: true } }, order_by: { updated_at: desc }) {
created_at created_at
created_by created_by
critical critical

View File

@@ -15,6 +15,7 @@ import FeatureWrapperComponent from "../../components/feature-wrapper/feature-wr
import InstanceRenderManager from "../../utils/instanceRenderMgr"; import InstanceRenderManager from "../../utils/instanceRenderMgr";
import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component"; import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component";
import { Card } from "antd"; import { Card } from "antd";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -54,8 +55,7 @@ export function AccountingPayablesContainer({ bodyshop, setBreadcrumbs, setSelec
if (error) return <AlertComponent message={error.message} type="error" />; if (error) return <AlertComponent message={error.message} type="error" />;
const noPath = const noPath =
!partnerVersion?.qbpath && !partnerVersion?.qbpath && !(bodyshop && (bodyshopHasDmsKey(bodyshop) || bodyshop?.accountingconfig?.qbo));
!(bodyshop && (bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.accountingconfig.qbo));
return ( return (
<div> <div>

View File

@@ -15,6 +15,7 @@ import FeatureWrapperComponent from "../../components/feature-wrapper/feature-wr
import InstanceRenderManager from "../../utils/instanceRenderMgr"; import InstanceRenderManager from "../../utils/instanceRenderMgr";
import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component"; import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component";
import { Card } from "antd"; import { Card } from "antd";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -52,9 +53,10 @@ export function AccountingPaymentsContainer({ bodyshop, setBreadcrumbs, setSelec
}); });
if (error) return <AlertComponent message={error.message} type="error" />; if (error) return <AlertComponent message={error.message} type="error" />;
const noPath = const noPath =
!partnerVersion?.qbpath && !partnerVersion?.qbpath && !(bodyshop && (bodyshopHasDmsKey(bodyshop) || bodyshop?.accountingconfig?.qbo));
!(bodyshop && (bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.accountingconfig.qbo));
return ( return (
<div> <div>
<FeatureWrapperComponent <FeatureWrapperComponent

View File

@@ -15,6 +15,7 @@ import FeatureWrapperComponent from "../../components/feature-wrapper/feature-wr
import InstanceRenderManager from "../../utils/instanceRenderMgr"; import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { Card } from "antd"; import { Card } from "antd";
import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component"; import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -57,8 +58,7 @@ export function AccountingReceivablesContainer({ bodyshop, setBreadcrumbs, setSe
if (error) return <AlertComponent message={error.message} type="error" />; if (error) return <AlertComponent message={error.message} type="error" />;
const noPath = const noPath =
!partnerVersion?.qbpath && !partnerVersion?.qbpath && !(bodyshop && (bodyshopHasDmsKey(bodyshop) || bodyshop?.accountingconfig?.qbo));
!(bodyshop && (bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.accountingconfig.qbo));
return ( return (
<div> <div>

View File

@@ -90,6 +90,7 @@ export function DmsContainer({ setBreadcrumbs, setSelectedHeader }) {
}); });
if (socket.disconnected) socket.connect(); if (socket.disconnected) socket.connect();
return () => { return () => {
socket.removeAllListeners(); socket.removeAllListeners();
socket.disconnect(); socket.disconnect();
@@ -139,17 +140,10 @@ export function DmsContainer({ setBreadcrumbs, setSelectedHeader }) {
</Space> </Space>
} }
> >
<DmsLogEvents socket={socket} logs={logs} /> <DmsLogEvents logs={logs} />
</Card> </Card>
</div> </div>
</Col> </Col>
</Row> </Row>
); );
} }
export const determineDmsType = (bodyshop) => {
if (bodyshop.cdk_dealerid) return "cdk";
else {
return "pbs";
}
};

View File

@@ -1,26 +1,31 @@
import { useQuery } from "@apollo/client"; import { useEffect, useMemo, useRef, useState } from "react";
import { Button, Card, Col, Result, Row, Select, Space } from "antd";
import queryString from "query-string";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { connect } from "react-redux";
import { useTranslation } from "react-i18next";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import SocketIO from "socket.io-client"; import queryString from "query-string";
import AlertComponent from "../../components/alert/alert.component"; import { useQuery } from "@apollo/client";
import DmsAllocationsSummary from "../../components/dms-allocations-summary/dms-allocations-summary.component"; import { Button, Card, Col, Result, Row, Select, Space, Switch } from "antd";
import DmsCustomerSelector from "../../components/dms-customer-selector/dms-customer-selector.component"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import DmsLogEvents from "../../components/dms-log-events/dms-log-events.component"; import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import DmsPostForm from "../../components/dms-post-form/dms-post-form.component"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component";
import { OwnerNameDisplayFunction } from "../../components/owner-name-display/owner-name-display.component";
import { auth } from "../../firebase/firebase.utils";
import { QUERY_JOB_EXPORT_DMS } from "../../graphql/jobs.queries"; import { QUERY_JOB_EXPORT_DMS } from "../../graphql/jobs.queries";
import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import InstanceRenderManager from "../../utils/instanceRenderMgr"; import InstanceRenderManager from "../../utils/instanceRenderMgr";
import AuditTrailMapping from "../../utils/AuditTrailMappings"; import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { bodyshopHasDmsKey, DMS_MAP, getDmsMode, isWssMode } from "../../utils/dmsUtils.js";
import legacySocket from "../../utils/legacySocket";
import { OwnerNameDisplayFunction } from "../../components/owner-name-display/owner-name-display.component";
import AlertComponent from "../../components/alert/alert.component";
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component";
import DmsPostForm from "../../components/dms-post-form/dms-post-form.component";
import DmsLogEvents from "../../components/dms-log-events/dms-log-events.component";
import DmsCustomerSelector from "../../components/dms-customer-selector/dms-customer-selector.component";
import DmsAllocationsSummary from "../../components/dms-allocations-summary/dms-allocations-summary.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
@@ -34,130 +39,357 @@ const mapDispatchToProps = (dispatch) => ({
export default connect(mapStateToProps, mapDispatchToProps)(DmsContainer); export default connect(mapStateToProps, mapDispatchToProps)(DmsContainer);
export const socket = SocketIO( const DMS_SOCKET_EVENTS = {
import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "", // for dev testing, [DMS_MAP.reynolds]: {
{ log: "rr-log-event",
path: "/ws", partialResult: "rr-export-job:result",
withCredentials: true, validationNeeded: "rr-validation-required",
auth: async (callback) => { exportSuccess: "export-success",
const token = auth.currentUser && (await auth.currentUser.getIdToken()); exportFailed: "export-failed"
callback({ token }); },
} [DMS_MAP.fortellis]: {
log: "fortellis-log-event",
exportSuccess: "export-success",
exportFailed: "export-failed"
},
[DMS_MAP.cdk]: {
log: "log-event",
exportSuccess: "export-success",
exportFailed: "export-failed"
},
[DMS_MAP.pbs]: {
log: "log-event",
exportSuccess: "export-success",
exportFailed: "export-failed"
} }
); };
export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, insertAuditTrail }) { export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, insertAuditTrail }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [logLevel, setLogLevel] = useState(determineDmsType(bodyshop) === "pbs" ? "INFO" : "DEBUG");
const history = useNavigate(); const history = useNavigate();
const [logs, setLogs] = useState([]);
const search = queryString.parse(useLocation().search); const search = queryString.parse(useLocation().search);
const { jobId } = search; const { jobId } = search;
const notification = useNotification(); const notification = useNotification();
const {
treatments: { Fortellis }
} = useSplitTreatments({
attributes: {},
names: ["Fortellis"],
splitKey: bodyshop.imexshopid
});
// Compute a single normalized mode and pick the proper socket
const mode = getDmsMode(bodyshop, Fortellis.treatment); // "rr" | "fortellis" | "cdk" | "pbs" | "none"
const { socket: wsssocket } = useSocket();
const activeSocket = useMemo(() => (isWssMode(mode) ? wsssocket : legacySocket), [mode, wsssocket]);
const [isConnected, setIsConnected] = useState(!!activeSocket?.connected);
// One place to set log level
const [logLevel, setLogLevel] = useState(mode === DMS_MAP.pbs ? "INFO" : "DEBUG");
const setActiveLogLevel = (level) => {
if (!activeSocket) return;
activeSocket.emit("set-log-level", level);
};
const [logs, setLogs] = useState([]);
const [detailsOpen, setDetailsOpen] = useState(false);
const [detailsNonce, setDetailsNonce] = useState(0);
const [colorizeJson, setColorizeJson] = useState(false);
const [rrOpenRoLimit, setRrOpenRoLimit] = useState(false);
const clearRrOpenRoLimit = () => setRrOpenRoLimit(false);
const [rrValidationPending, setrrValidationPending] = useState(false);
const { loading, error, data } = useQuery(QUERY_JOB_EXPORT_DMS, { const { loading, error, data } = useQuery(QUERY_JOB_EXPORT_DMS, {
variables: { id: jobId }, variables: { id: jobId },
skip: !jobId, skip: !jobId,
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only" nextFetchPolicy: "network-only"
}); });
const logsRef = useRef(null); const logsRef = useRef(null);
const toggleDetailsAll = () => {
setDetailsOpen((v) => !v);
setDetailsNonce((n) => n + 1);
};
// Channel names per mode to avoid branching everywhere
const channels = useMemo(() => DMS_SOCKET_EVENTS[mode] || {}, [mode]);
const providerLabel = useMemo(
() =>
({
[DMS_MAP.reynolds]: "Reynolds",
[DMS_MAP.fortellis]: "Fortellis",
[DMS_MAP.cdk]: "CDK",
[DMS_MAP.pbs]: "PBS"
})[mode] || "DMS",
[mode]
);
const transportLabel = isWssMode(mode) ? "App Socket (WSS)" : "Legacy Socket (WS)";
const bannerMessage = `Posting to ${providerLabel} | ${transportLabel} | ${
isConnected ? "Connected" : "Disconnected"
}`;
const handleExportFailed = (payload = {}) => {
const { title, friendlyMessage, error: errText, severity, errorCode, vendorStatusCode } = payload;
const msg =
friendlyMessage ||
errText ||
t("dms.errors.exportfailedgeneric", "We couldn't complete the export. Please try again.");
const vendorTitle = title || (mode === DMS_MAP.reynolds ? "Reynolds" : "DMS");
const isRrOpenRoLimit =
mode === DMS_MAP.reynolds &&
(vendorStatusCode === 507 ||
/MAX_OPEN_ROS/i.test(String(errorCode || "")) ||
/maximum number of open repair orders/i.test(String(msg || "").toLowerCase()));
const sev = severity || (isRrOpenRoLimit ? "warning" : "error");
if (!isRrOpenRoLimit) {
const notifyKind = sev === "warning" && typeof notification.warning === "function" ? "warning" : "error";
notification[notifyKind]({ message: vendorTitle, description: msg, duration: 10 });
} else {
setRrOpenRoLimit(true);
}
setLogs((prev) => [
...prev,
{
timestamp: new Date(),
level: (sev || "error").toUpperCase(),
message: `${vendorTitle}: ${msg}`,
meta: { errorCode, vendorStatusCode, raw: payload, blockedByOpenRoLimit: !!isRrOpenRoLimit }
}
]);
};
// keep this in sync if mode/socket flips
useEffect(() => {
setIsConnected(!!activeSocket?.connected);
}, [activeSocket]);
useEffect(() => { useEffect(() => {
document.title = t("titles.dms", { document.title = t("titles.dms", {
app: InstanceRenderManager({ app: InstanceRenderManager({ imex: "$t(titles.imexonline)", rome: "$t(titles.romeonline)" })
imex: "$t(titles.imexonline)",
rome: "$t(titles.romeonline)"
})
}); });
setSelectedHeader("dms"); setSelectedHeader("dms");
setBreadcrumbs([ setBreadcrumbs([
{ { link: "/manage/accounting/receivables", label: t("titles.bc.accounting-receivables") },
link: "/manage/accounting/receivables", { link: "/manage/dms", label: t("titles.bc.dms") }
label: t("titles.bc.accounting-receivables")
},
{
link: "/manage/dms",
label: t("titles.bc.dms")
}
]); ]);
}, [t, setBreadcrumbs, setSelectedHeader]); }, [t, setBreadcrumbs, setSelectedHeader]);
// Socket wiring (mode-aware)
useEffect(() => { useEffect(() => {
socket.on("connect", () => socket.emit("set-log-level", logLevel)); if (!activeSocket) return;
socket.on("reconnect", () => {
setLogs((logs) => { // Connect legacy socket if needed
return [ if (!isWssMode(mode)) {
...logs, if (activeSocket.disconnected) activeSocket.connect();
{ }
timestamp: new Date(),
level: "warn", // Set log level now and on connect/reconnect
message: "Reconnected to CDK Export Service" setActiveLogLevel(logLevel);
}
]; const onConnect = () => {
}); setIsConnected(true);
}); setActiveLogLevel(logLevel);
socket.on("connect_error", (err) => { };
const onDisconnect = () => setIsConnected(false);
const onReconnect = () => {
setIsConnected(true);
setLogs((prev) => [
...prev,
{
timestamp: new Date(),
level: "warn",
message: `Reconnected to ${mode === DMS_MAP.reynolds ? "RR" : mode === DMS_MAP.fortellis ? "Fortellis" : "DMS"} Export Service`
}
]);
};
const onConnectError = (err) => {
// Legacy and WSS both emit this
console.log(`connect_error due to ${err}`, err); console.log(`connect_error due to ${err}`, err);
notification.error({ message: err.message }); notification.error({ message: err.message });
}); };
socket.on("log-event", (payload) => {
setLogs((logs) => { activeSocket.on("disconnect", onDisconnect);
return [...logs, payload]; activeSocket.on("connect", onConnect);
}); activeSocket.on("reconnect", onReconnect);
}); activeSocket.on("connect_error", onConnectError);
socket.on("export-success", (payload) => {
notification.success({ // Logs
message: t("jobs.successes.exported") const onLog =
}); mode === DMS_MAP.reynolds
? (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
};
setLogs((prev) => [...prev, normalized]);
}
: (payload) => setLogs((prev) => [...prev, payload]);
if (channels.log) activeSocket.on(channels.log, onLog);
// Success / Failed
const onExportSuccess = (payload) => {
const jobIdResolved = payload?.jobId ?? payload;
notification.success({ message: t("jobs.successes.exported") });
// Clear RR Validation flag if any
setrrValidationPending(false);
insertAuditTrail({ insertAuditTrail({
jobid: payload, jobid: jobIdResolved,
operation: AuditTrailMapping.jobexported(), operation: AuditTrailMapping.jobexported(),
type: "jobexported" type: "jobexported"
}); });
history("/manage/accounting/receivables"); history("/manage/accounting/receivables");
});
if (socket.disconnected) socket.connect();
return () => {
socket.removeAllListeners();
socket.disconnect();
}; };
}, []);
if (channels.exportSuccess) activeSocket.on(channels.exportSuccess, onExportSuccess);
if (channels.exportFailed) activeSocket.on(channels.exportFailed, handleExportFailed);
// RR-only extras
const onPartialResult = () => {
setrrValidationPending(true);
setLogs((prev) => [
...prev,
{
timestamp: new Date(),
level: "INFO",
message:
"Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize."
}
]);
notification.info({
message: "Reynolds RO created",
description:
"Complete validation in Reynolds, then click Finished/Close to finalize and mark this export complete.",
duration: 8
});
};
const onValidationRequired = (payload) => {
setrrValidationPending(true);
setLogs((prev) => [
...prev,
{
timestamp: new Date(),
level: "INFO",
message:
"Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize.",
meta: { payload }
}
]);
};
if (mode === DMS_MAP.reynolds && channels.partialResult) activeSocket.on(channels.partialResult, onPartialResult);
if (mode === DMS_MAP.reynolds && channels.validationNeeded)
activeSocket.on(channels.validationNeeded, onValidationRequired);
return () => {
activeSocket.off("connect", onConnect);
activeSocket.off("reconnect", onReconnect);
activeSocket.off("connect_error", onConnectError);
activeSocket.off("disconnect", onDisconnect);
if (channels.log) activeSocket.off(channels.log, onLog);
if (channels.exportSuccess) activeSocket.off(channels.exportSuccess, onExportSuccess);
if (channels.exportFailed) activeSocket.off(channels.exportFailed, handleExportFailed);
if (mode === DMS_MAP.reynolds && channels.partialResult)
activeSocket.off(channels.partialResult, onPartialResult);
if (mode === DMS_MAP.reynolds && channels.validationNeeded)
activeSocket.off(channels.validationNeeded, onValidationRequired);
// Only tear down legacy socket listeners; don't disconnect WSS from here
if (!isWssMode(mode)) {
activeSocket.removeAllListeners();
activeSocket.disconnect();
}
};
}, [mode, activeSocket, channels, logLevel, notification, t, insertAuditTrail, history]);
// RR finalize callback (unchanged public behavior)
const handleRrValidationFinished = () => {
if (!jobId) return;
if (!isWssMode(mode)) return; // RR is WSS-only
activeSocket.emit("rr-finalize-repair-order", { jobId }, (ack) => {
if (ack?.ok) return;
if (ack?.error) notification.error({ message: ack.error });
});
};
if (loading) return <LoadingSpinner />; if (loading) return <LoadingSpinner />;
if (error) return <AlertComponent message={error.message} type="error" />; if (error) return <AlertComponent message={error.message} type="error" />;
if (!jobId || !(bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber) || !(data && data.jobs_by_pk)) if (!jobId || !bodyshopHasDmsKey(bodyshop) || !data?.jobs_by_pk)
return <Result status="404" title={t("general.errors.notfound")} />; return <Result status="404" title={t("general.errors.notfound")} />;
if (data.jobs_by_pk && data.jobs_by_pk.date_exported) if (data.jobs_by_pk?.date_exported) return <Result status="warning" title={t("dms.errors.alreadyexported")} />;
return <Result status="warning" title={t("dms.errors.alreadyexported")} />;
return ( return (
<div> <div>
<AlertComponent style={{ marginBottom: 10 }} message={bannerMessage} type="warning" showIcon closable />
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col md={24} lg={10}> <Col md={24} lg={10}>
<DmsAllocationsSummary <DmsAllocationsSummary
title={ title={
<span> <span>
<Link to={`/manage/jobs/${data && data.jobs_by_pk.id}`}>{`${ <Link
data && data.jobs_by_pk && data.jobs_by_pk.ro_number to={`/manage/jobs/${data && data.jobs_by_pk.id}`}
}`}</Link> >{`${data?.jobs_by_pk && data.jobs_by_pk.ro_number}`}</Link>
{` | ${OwnerNameDisplayFunction(data.jobs_by_pk)} | ${ {` | ${OwnerNameDisplayFunction(data.jobs_by_pk)} | ${data.jobs_by_pk.v_model_yr || ""} ${data.jobs_by_pk.v_make_desc || ""} ${data.jobs_by_pk.v_model_desc || ""}`}
data.jobs_by_pk.v_model_yr || ""
} ${data.jobs_by_pk.v_make_desc || ""} ${data.jobs_by_pk.v_model_desc || ""}`}
</span> </span>
} }
socket={socket} socket={activeSocket}
jobId={jobId} jobId={jobId}
mode={mode}
/> />
</Col> </Col>
<Col md={24} lg={14}> <Col md={24} lg={14}>
<DmsPostForm socket={socket} jobId={jobId} job={data && data.jobs_by_pk} logsRef={logsRef} /> <DmsPostForm socket={activeSocket} job={data?.jobs_by_pk} logsRef={logsRef} mode={mode} />
</Col> </Col>
<DmsCustomerSelector /> <DmsCustomerSelector
jobid={jobId}
bodyshop={bodyshop}
socket={activeSocket}
mode={mode}
rrOptions={{
openRoLimit: rrOpenRoLimit,
onOpenRoFinished: clearRrOpenRoLimit,
validationPending: rrValidationPending,
onValidationFinished: handleRrValidationFinished
}}
/>
<Col span={24}> <Col span={24}>
<div ref={logsRef}> <div ref={logsRef}>
@@ -165,14 +397,22 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
title={t("jobs.labels.dms.logs")} title={t("jobs.labels.dms.logs")}
extra={ extra={
<Space wrap> <Space wrap>
<Switch
checked={colorizeJson}
onChange={setColorizeJson}
checkedChildren="Color JSON"
unCheckedChildren="Plain JSON"
/>
<Button onClick={toggleDetailsAll}>{detailsOpen ? "Collapse All" : "Expand All"}</Button>
<Select <Select
placeholder="Log Level" placeholder="Log Level"
value={logLevel} value={logLevel}
onChange={(value) => { onChange={(value) => {
setLogLevel(value); setLogLevel(value);
socket.emit("set-log-level", value); setActiveLogLevel(value);
}} }}
> >
<Select.Option key="SILLY">SILLY</Select.Option>
<Select.Option key="DEBUG">DEBUG</Select.Option> <Select.Option key="DEBUG">DEBUG</Select.Option>
<Select.Option key="INFO">INFO</Select.Option> <Select.Option key="INFO">INFO</Select.Option>
<Select.Option key="WARN">WARN</Select.Option> <Select.Option key="WARN">WARN</Select.Option>
@@ -182,8 +422,11 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
<Button <Button
onClick={() => { onClick={() => {
setLogs([]); setLogs([]);
socket.disconnect(); if (isWssMode(mode)) {
socket.connect(); setActiveLogLevel(logLevel);
}
activeSocket.disconnect();
activeSocket.connect();
}} }}
> >
Reconnect Reconnect
@@ -191,7 +434,12 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
</Space> </Space>
} }
> >
<DmsLogEvents socket={socket} logs={logs} /> <DmsLogEvents
logs={logs}
detailsOpen={detailsOpen}
detailsNonce={detailsNonce}
colorizeJson={colorizeJson}
/>
</Card> </Card>
</div> </div>
</Col> </Col>
@@ -199,10 +447,3 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
</div> </div>
); );
} }
export const determineDmsType = (bodyshop) => {
if (bodyshop.cdk_dealerid) return "cdk";
else {
return "pbs";
}
};

View File

@@ -42,6 +42,7 @@ import { setModalContext } from "../../redux/modals/modals.actions.js";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings"; import AuditTrailMapping from "../../utils/AuditTrailMappings";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -69,6 +70,8 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const notification = useNotification(); const notification = useNotification();
const hasDMSKey = bodyshopHasDmsKey(bodyshop);
const { const {
treatments: { Qb_Multi_Ar, ClosingPeriod } treatments: { Qb_Multi_Ar, ClosingPeriod }
} = useSplitTreatments({ } = useSplitTreatments({
@@ -191,7 +194,7 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
{t("general.actions.close")} {t("general.actions.close")}
</Button> </Button>
</Popconfirm> </Popconfirm>
{(bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid) && ( {bodyshopHasDmsKey(bodyshop) && (
<Link to={`/manage/dms?jobId=${job.id}`}> <Link to={`/manage/dms?jobId=${job.id}`}>
<Button disabled={job.date_exported || !jobRO}>{t("jobs.actions.sendtodms")}</Button> <Button disabled={job.date_exported || !jobRO}>{t("jobs.actions.sendtodms")}</Button>
</Link> </Link>
@@ -311,7 +314,7 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
<Switch /> <Switch />
</Form.Item> </Form.Item>
)} )}
{(bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber) && ( {hasDMSKey && (
<Form.Item <Form.Item
label={t("jobs.fields.kmin")} label={t("jobs.fields.kmin")}
name="kmin" name="kmin"
@@ -324,7 +327,7 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
<InputNumber precision={0} disabled={jobRO} /> <InputNumber precision={0} disabled={jobRO} />
</Form.Item> </Form.Item>
)} )}
{(bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber) && ( {hasDMSKey && (
<Form.Item <Form.Item
label={t("jobs.fields.kmout")} label={t("jobs.fields.kmout")}
name="kmout" name="kmout"
@@ -348,7 +351,7 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
<InputNumber precision={0} disabled={jobRO} /> <InputNumber precision={0} disabled={jobRO} />
</Form.Item> </Form.Item>
)} )}
{(bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber) && ( {hasDMSKey && (
<Form.Item <Form.Item
label={t("jobs.fields.dms_allocation")} label={t("jobs.fields.dms_allocation")}
name="dms_allocation" name="dms_allocation"

View File

@@ -50,7 +50,7 @@ import {
} from "./user.actions"; } from "./user.actions";
import UserActionTypes from "./user.types"; import UserActionTypes from "./user.types";
//import * as amplitude from '@amplitude/analytics-browser'; //import * as amplitude from '@amplitude/analytics-browser';
import posthog from "posthog-js"; import posthog from 'posthog-js';
const fpPromise = FingerprintJS.load(); const fpPromise = FingerprintJS.load();
@@ -269,11 +269,11 @@ export function* signInSuccessSaga({ payload }) {
instanceSeg, instanceSeg,
...(isParts ...(isParts
? [ ? [
InstanceRenderManager({ InstanceRenderManager({
imex: "ImexPartsManagement", imex: "ImexPartsManagement",
rome: "RomePartsManagement" rome: "RomePartsManagement"
}) })
] ]
: []) : [])
]; ];
window.$crisp.push(["set", "session:segments", [segs]]); window.$crisp.push(["set", "session:segments", [segs]]);
@@ -375,31 +375,17 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
const isParts = yield select((state) => state.application.isPartsEntry === true); const isParts = yield select((state) => state.application.isPartsEntry === true);
const instanceSeg = InstanceRenderManager({ imex: "imex", rome: "rome" }); const instanceSeg = InstanceRenderManager({ imex: "imex", rome: "rome" });
const featureSegments = let featureSegments;
payload.features?.allAccess === true if (payload.features?.allAccess === true) {
? ["allAccess"] featureSegments = ["allAccess"];
: [ } else {
"basic", const featureKeys = Object.keys(payload.features).filter(
...Object.keys(payload.features).filter( (key) =>
(key) => payload.features[key] === true ||
payload.features[key] === true || (typeof payload.features[key] === "string" && !isNaN(Date.parse(payload.features[key])))
(typeof payload.features[key] === "string" && !isNaN(Date.parse(payload.features[key]))) );
) featureSegments = ["basic", ...featureKeys];
]; }
const additionalSegments = [
payload.cdk_dealerid && "CDK",
payload.pbs_serialnumber && "PBS",
// payload.rr_dealerid && "Reynolds",
payload.accountingconfig.qbo === true && "QBO",
payload.accountingconfig.qbo === false &&
!payload.cdk_dealerid &&
!payload.pbs_serialnumber &&
// !payload.rr_dealerid &&
"QBD"
].filter(Boolean);
featureSegments.push(...additionalSegments);
const regionSeg = payload.region_config ? `region:${payload.region_config}` : null; const regionSeg = payload.region_config ? `region:${payload.region_config}` : null;
const segments = [instanceSeg, ...(regionSeg ? [regionSeg] : []), ...featureSegments]; const segments = [instanceSeg, ...(regionSeg ? [regionSeg] : []), ...featureSegments];

View File

@@ -558,6 +558,14 @@
"responsibilitycenter_tax_tier": "Tax {{typeNum}} Tier {{typeNumIterator}}", "responsibilitycenter_tax_tier": "Tax {{typeNum}} Tier {{typeNumIterator}}",
"responsibilitycenter_tax_type": "Tax {{typeNum}} Type", "responsibilitycenter_tax_type": "Tax {{typeNum}} Type",
"responsibilitycenters": { "responsibilitycenters": {
"gogcode": "GOG Code (BreakOut)",
"item_type": "Item Type",
"item_type_gog": "GOG",
"item_type_paint": "Paint Materials",
"item_type_freight": "Freight",
"taxable_flag": "Taxable?",
"taxable": "Taxable",
"nontaxable": "Non-taxable",
"ap": "Accounts Payable", "ap": "Accounts Payable",
"ar": "Accounts Receivable", "ar": "Accounts Receivable",
"ats": "ATS", "ats": "ATS",
@@ -688,6 +696,7 @@
"payers": "Payers" "payers": "Payers"
}, },
"cdk_dealerid": "CDK Dealer ID", "cdk_dealerid": "CDK Dealer ID",
"rr_dealerid": "Reynolds Store Number",
"costsmapping": "Costs Mapping", "costsmapping": "Costs Mapping",
"dms_allocations": "DMS Allocations", "dms_allocations": "DMS Allocations",
"pbs_serialnumber": "PBS Serial Number", "pbs_serialnumber": "PBS Serial Number",
@@ -1035,7 +1044,7 @@
"alreadyexported": "This job has already been sent to the DMS. If you need to resend it, please use admin permissions to mark the job for re-export." "alreadyexported": "This job has already been sent to the DMS. If you need to resend it, please use admin permissions to mark the job for re-export."
}, },
"labels": { "labels": {
"refreshallocations": "Refresh to see DMS Allocataions." "refreshallocations": "Refresh to see DMS Allocations."
} }
}, },
"documents": { "documents": {
@@ -1212,6 +1221,8 @@
}, },
"general": { "general": {
"actions": { "actions": {
"select": "Select",
"optional": "Optional",
"add": "Add", "add": "Add",
"autoupdate": "{{app}} will automatically update in {{time}} seconds. Please save all changes.", "autoupdate": "{{app}} will automatically update in {{time}} seconds. Please save all changes.",
"calculate": "Calculate", "calculate": "Calculate",
@@ -1777,6 +1788,8 @@
"id": "DMS ID", "id": "DMS ID",
"inservicedate": "In Service Date", "inservicedate": "In Service Date",
"journal": "Journal #", "journal": "Journal #",
"make_override": "Make Override",
"advisor": "Advisor #",
"lines": "Posting Lines", "lines": "Posting Lines",
"name1": "Customer Name", "name1": "Customer Name",
"payer": { "payer": {
@@ -1784,7 +1797,8 @@
"control_type": "Control Type", "control_type": "Control Type",
"controlnumber": "Control Number", "controlnumber": "Control Number",
"dms_acctnumber": "DMS Account #", "dms_acctnumber": "DMS Account #",
"name": "Payer Name" "name": "Payer Name",
"payer_type": "Payer"
}, },
"sale": "Sale", "sale": "Sale",
"sale_dms_acctnumber": "Sale DMS Acct #", "sale_dms_acctnumber": "Sale DMS Acct #",
@@ -1851,7 +1865,7 @@
"loss_of_use": "Loss of Use", "loss_of_use": "Loss of Use",
"lost_sale_reason": "Lost Sale Reason", "lost_sale_reason": "Lost Sale Reason",
"ma2s": "2 Stage Paint", "ma2s": "2 Stage Paint",
"ma3s": "3 Stage Pain", "ma3s": "3 Stage Paint",
"mabl": "MABL?", "mabl": "MABL?",
"macs": "MACS?", "macs": "MACS?",
"mahw": "Hazardous Waste", "mahw": "Hazardous Waste",
@@ -2430,8 +2444,7 @@
"selectmedia": "Select Media", "selectmedia": "Select Media",
"sentby": "Sent by {{by}} at {{time}}", "sentby": "Sent by {{by}} at {{time}}",
"typeamessage": "Send a message...", "typeamessage": "Send a message...",
"unarchive": "Unarchive", "unarchive": "Unarchive"
"mark_unread": "Mark as unread"
}, },
"render": { "render": {
"conversation_list": "Conversation List" "conversation_list": "Conversation List"
@@ -3210,7 +3223,6 @@
"parts_not_recieved_vendor": "Parts Not Received by Vendor", "parts_not_recieved_vendor": "Parts Not Received by Vendor",
"parts_received_not_scheduled": "Parts Received for Jobs Not Scheduled", "parts_received_not_scheduled": "Parts Received for Jobs Not Scheduled",
"payments_by_date": "Payments by Date", "payments_by_date": "Payments by Date",
"payments_by_date_excel": "Payments by Date - Excel",
"payments_by_date_payment": "Payments by Date and Payment Type", "payments_by_date_payment": "Payments by Date and Payment Type",
"payments_by_date_type": "Payments by Date and Customer Type", "payments_by_date_type": "Payments by Date and Customer Type",
"production_by_category": "Production by Category", "production_by_category": "Production by Category",

View File

@@ -558,6 +558,14 @@
"responsibilitycenter_tax_tier": "", "responsibilitycenter_tax_tier": "",
"responsibilitycenter_tax_type": "", "responsibilitycenter_tax_type": "",
"responsibilitycenters": { "responsibilitycenters": {
"gogcode": "",
"item_type": "Item Type",
"item_type_gog": "",
"item_type_paint": "",
"item_type_freight": "",
"taxable_flag": "",
"taxable": "",
"nontaxable": "",
"ap": "", "ap": "",
"ar": "", "ar": "",
"ats": "", "ats": "",
@@ -688,6 +696,7 @@
"payers": "" "payers": ""
}, },
"cdk_dealerid": "", "cdk_dealerid": "",
"rr_dealerid": "",
"costsmapping": "", "costsmapping": "",
"dms_allocations": "", "dms_allocations": "",
"pbs_serialnumber": "", "pbs_serialnumber": "",
@@ -1772,6 +1781,8 @@
"dms_make": "", "dms_make": "",
"dms_model": "", "dms_model": "",
"dms_model_override": "", "dms_model_override": "",
"make_override": "",
"advisor": "",
"dms_unsold": "", "dms_unsold": "",
"dms_wip_acctnumber": "", "dms_wip_acctnumber": "",
"id": "", "id": "",
@@ -2430,8 +2441,7 @@
"selectmedia": "", "selectmedia": "",
"sentby": "", "sentby": "",
"typeamessage": "Enviar un mensaje...", "typeamessage": "Enviar un mensaje...",
"unarchive": "", "unarchive": ""
"mark_unread": ""
}, },
"render": { "render": {
"conversation_list": "" "conversation_list": ""
@@ -3210,7 +3220,6 @@
"parts_not_recieved_vendor": "", "parts_not_recieved_vendor": "",
"parts_received_not_scheduled": "", "parts_received_not_scheduled": "",
"payments_by_date": "", "payments_by_date": "",
"payments_by_date_excel": "",
"payments_by_date_payment": "", "payments_by_date_payment": "",
"payments_by_date_type": "", "payments_by_date_type": "",
"production_by_category": "", "production_by_category": "",

View File

@@ -558,6 +558,14 @@
"responsibilitycenter_tax_tier": "", "responsibilitycenter_tax_tier": "",
"responsibilitycenter_tax_type": "", "responsibilitycenter_tax_type": "",
"responsibilitycenters": { "responsibilitycenters": {
"gogcode": "",
"item_type": "Item Type",
"item_type_gog": "",
"item_type_paint": "",
"item_type_freight": "",
"taxable_flag": "",
"taxable": "",
"nontaxable": "",
"ap": "", "ap": "",
"ar": "", "ar": "",
"ats": "", "ats": "",
@@ -688,6 +696,7 @@
"payers": "" "payers": ""
}, },
"cdk_dealerid": "", "cdk_dealerid": "",
"rr_dealerid": "",
"costsmapping": "", "costsmapping": "",
"dms_allocations": "", "dms_allocations": "",
"pbs_serialnumber": "", "pbs_serialnumber": "",
@@ -1772,6 +1781,8 @@
"dms_make": "", "dms_make": "",
"dms_model": "", "dms_model": "",
"dms_model_override": "", "dms_model_override": "",
"make_override": "",
"advisor": "",
"dms_unsold": "", "dms_unsold": "",
"dms_wip_acctnumber": "", "dms_wip_acctnumber": "",
"id": "", "id": "",
@@ -2430,8 +2441,7 @@
"selectmedia": "", "selectmedia": "",
"sentby": "", "sentby": "",
"typeamessage": "Envoyer un message...", "typeamessage": "Envoyer un message...",
"unarchive": "", "unarchive": ""
"mark_unread": ""
}, },
"render": { "render": {
"conversation_list": "" "conversation_list": ""
@@ -3210,7 +3220,6 @@
"parts_not_recieved_vendor": "", "parts_not_recieved_vendor": "",
"parts_received_not_scheduled": "", "parts_received_not_scheduled": "",
"payments_by_date": "", "payments_by_date": "",
"payments_by_date_excel": "",
"payments_by_date_payment": "", "payments_by_date_payment": "",
"payments_by_date_type": "", "payments_by_date_type": "",
"production_by_category": "", "production_by_category": "",

View File

@@ -148,26 +148,6 @@ const cache = new InMemoryCache({
Query: { Query: {
fields: { fields: {
// Note: This is required because we switch from a read to an unread state with a toggle, // Note: This is required because we switch from a read to an unread state with a toggle,
conversations: {
keyArgs: ["where", "order_by"], // keep separate caches for archived/unarchived + sort
merge(existing = [], incoming = [], { args, readField }) {
const offset = args?.offset ?? 0;
const merged = existing ? existing.slice(0) : [];
for (let i = 0; i < incoming.length; i++) {
merged[offset + i] = incoming[i];
}
// Deduplicate by id (important when you also upsert via sockets)
const seen = new Set();
return merged.filter((ref) => {
const id = readField("id", ref);
if (!id || seen.has(id)) return false;
seen.add(id);
return true;
});
}
},
notifications: { notifications: {
merge(existing = [], incoming = [], { readField }) { merge(existing = [], incoming = [], { readField }) {
// Create a map to deduplicate by __ref // Create a map to deduplicate by __ref

View File

@@ -1218,18 +1218,6 @@ export const TemplateList = (type, context) => {
}, },
group: "customers" group: "customers"
}, },
payments_by_date_excel: {
title: i18n.t("reportcenter.templates.payments_by_date_excel"),
subject: i18n.t("reportcenter.templates.payments_by_date_excel"),
key: "payments_by_date_excel",
reporttype: "excel",
disabled: false,
rangeFilter: {
object: i18n.t("reportcenter.labels.objects.payments"),
field: i18n.t("payments.fields.date")
},
group: "customers"
},
schedule: { schedule: {
title: i18n.t("reportcenter.templates.schedule"), title: i18n.t("reportcenter.templates.schedule"),
subject: i18n.t("reportcenter.templates.schedule"), subject: i18n.t("reportcenter.templates.schedule"),

View File

@@ -0,0 +1,72 @@
/**
* DMS type mapping constants.
* CAREFUL: the values here are used as canonical "mode" strings elsewhere in the app.
* @type {{reynolds: string, cdk: string, pbs: string, fortellis: string}}
*/
export const DMS_MAP = {
reynolds: "rr",
cdk: "cdk",
pbs: "pbs",
fortellis: "fortellis"
};
/**
* Determines the DMS type for a given bodyshop object.
* @param bodyshop
* @returns {*|string}
*/
export const determineDMSTypeByBodyshop = (bodyshop) => {
const dmsMapping = {
cdk_dealerid: DMS_MAP.cdk,
pbs_serialnumber: DMS_MAP.pbs,
rr_dealerid: DMS_MAP.reynolds
};
return Object.keys(dmsMapping).find((key) => bodyshop[key])
? dmsMapping[Object.keys(dmsMapping).find((key) => bodyshop[key])]
: DMS_MAP.pbs;
};
/**
* Determines the translation key for a given DMS type.
* @param dmsType
* @returns {*|string}
*/
export const determineDmsTypeTranslationKey = (dmsType) => {
const dmsTypeMapping = {
[DMS_MAP.cdk]: "bodyshop.labels.dms.cdk",
[DMS_MAP.pbs]: "bodyshop.labels.dms.pbs",
[DMS_MAP.reynolds]: "bodyshop.labels.dms.rr"
};
return dmsTypeMapping[dmsType] || dmsTypeMapping[DMS_MAP.pbs];
};
/**
* Returns a normalized "mode" we can switch on:
* @param bodyshop
* @param fortellisTreatment
* @returns {*|string|string}
*/
export const getDmsMode = (bodyshop, fortellisTreatment) => {
const base = determineDMSTypeByBodyshop(bodyshop); // "rr" | "cdk" | "pbs" | undefined
if (base === DMS_MAP.cdk && fortellisTreatment === "on") return DMS_MAP.fortellis;
return base ?? "none";
};
/**
* Checks if the DMS mode uses WSS.
* @param mode
* @returns {boolean}
*/
export const isWssMode = (mode) => {
return mode === DMS_MAP.reynolds || mode === DMS_MAP.fortellis;
};
/**
* Checks if the bodyshop has any DMS key configured.
* @param bodyshop
* @returns {*|string}
*/
export const bodyshopHasDmsKey = (bodyshop) =>
bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid;

View File

@@ -0,0 +1,16 @@
// client/src/utils/legacySocket.js
import SocketIO from "socket.io-client";
import { auth } from "../firebase/firebase.utils";
// Create once, reuse everywhere.
const legacySocket = SocketIO(import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "", {
path: "/ws",
withCredentials: true,
autoConnect: false,
auth: async (callback) => {
const token = auth.currentUser && (await auth.currentUser.getIdToken());
callback({ token });
}
});
export default legacySocket;

View File

@@ -31,8 +31,7 @@ if (!import.meta.env.DEV) {
"Module specifier, 'fs' does not start", "Module specifier, 'fs' does not start",
"Module specifier, 'zlib' does not start with", "Module specifier, 'zlib' does not start with",
"Messaging: This browser doesn't support the API's required to use the Firebase SDK.", "Messaging: This browser doesn't support the API's required to use the Firebase SDK.",
"Failed to update a ServiceWorker for scope", "Failed to update a ServiceWorker for scope"
"Network Error"
], ],
integrations: [ integrations: [
// See docs for support of different versions of variation of react router // See docs for support of different versions of variation of react router

View File

@@ -24,13 +24,11 @@ const lightningCssTargets = browserslistToTargets(
}) })
); );
const pstFormatter = new Intl.DateTimeFormat("en-CA", { const currentDatePST = new Date()
timeZone: "America/Los_Angeles", .toLocaleDateString("en-US", { timeZone: "America/Los_Angeles", year: "numeric", month: "2-digit", day: "2-digit" })
year: "numeric", .split("/")
month: "2-digit", .reverse()
day: "2-digit" .join("-");
});
const currentDatePST = pstFormatter.format(new Date());
const getFormattedTimestamp = () => const getFormattedTimestamp = () =>
new Date().toLocaleTimeString("en-US", { hour12: true }).replace("AM", "a.m.").replace("PM", "p.m."); new Date().toLocaleTimeString("en-US", { hour12: true }).replace("AM", "a.m.").replace("PM", "p.m.");

View File

@@ -1156,11 +1156,7 @@
enable_manual: false enable_manual: false
update: update:
columns: columns:
- imexshopid
- timezone
- shopname - shopname
- notification_followers
- state
- md_order_statuses - md_order_statuses
retry_conf: retry_conf:
interval_sec: 10 interval_sec: 10
@@ -3702,7 +3698,6 @@
- deliverchecklist - deliverchecklist
- depreciation_taxes - depreciation_taxes
- dms_allocation - dms_allocation
- dms_id
- driveable - driveable
- employee_body - employee_body
- employee_csr - employee_csr
@@ -3980,7 +3975,6 @@
- deliverchecklist - deliverchecklist
- depreciation_taxes - depreciation_taxes
- dms_allocation - dms_allocation
- dms_id
- driveable - driveable
- employee_body - employee_body
- employee_csr - employee_csr
@@ -4270,7 +4264,6 @@
- deliverchecklist - deliverchecklist
- depreciation_taxes - depreciation_taxes
- dms_allocation - dms_allocation
- dms_id
- driveable - driveable
- employee_body - employee_body
- employee_csr - employee_csr

View File

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

View File

@@ -1,2 +0,0 @@
alter table "public"."media_analytics" add column "unique_documents" numeric
null;

View File

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

View File

@@ -1,2 +0,0 @@
alter table "public"."media_analytics" add column "duplicate_documents" numeric
null;

View File

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

View File

@@ -1,2 +0,0 @@
alter table "public"."media_analytics_detail" add column "unique_documents" numeric
null;

View File

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

View File

@@ -1,2 +0,0 @@
alter table "public"."media_analytics_detail" add column "duplicate_documents" numeric
null;

View File

@@ -1 +0,0 @@
alter table "public"."media_analytics_detail" rename column "unique_document_count" to "unique_documents";

View File

@@ -1 +0,0 @@
alter table "public"."media_analytics_detail" rename column "unique_documents" to "unique_document_count";

View File

@@ -1 +0,0 @@
alter table "public"."media_analytics_detail" rename column "duplicate_count" to "duplicate_documents";

View File

@@ -1 +0,0 @@
alter table "public"."media_analytics_detail" rename column "duplicate_documents" to "duplicate_count";

View File

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

View File

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

2813
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,24 +18,25 @@
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js" "job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.901.0", "@aws-sdk/client-cloudwatch-logs": "^3.932.0",
"@aws-sdk/client-elasticache": "^3.901.0", "@aws-sdk/client-elasticache": "^3.932.0",
"@aws-sdk/client-s3": "^3.901.0", "@aws-sdk/client-s3": "^3.932.0",
"@aws-sdk/client-secrets-manager": "^3.901.0", "@aws-sdk/client-secrets-manager": "^3.932.0",
"@aws-sdk/client-ses": "^3.901.0", "@aws-sdk/client-ses": "^3.932.0",
"@aws-sdk/credential-provider-node": "^3.901.0", "@aws-sdk/credential-provider-node": "^3.932.0",
"@aws-sdk/lib-storage": "^3.903.0", "@aws-sdk/lib-storage": "^3.932.0",
"@aws-sdk/s3-request-presigner": "^3.901.0", "@aws-sdk/s3-request-presigner": "^3.932.0",
"@opensearch-project/opensearch": "^2.13.0", "@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1", "@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"aws4": "^1.13.2", "aws4": "^1.13.2",
"axios": "^1.12.2", "axios": "^1.13.2",
"axios-curlirize": "^2.0.0",
"better-queue": "^3.8.12", "better-queue": "^3.8.12",
"bullmq": "^5.61.0", "bullmq": "^5.63.2",
"chart.js": "^4.5.0", "chart.js": "^4.5.1",
"cloudinary": "^2.7.0", "cloudinary": "^2.8.0",
"compression": "^1.8.1", "compression": "^1.8.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -43,40 +44,43 @@
"dinero.js": "^1.9.1", "dinero.js": "^1.9.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.1", "express": "^4.21.1",
"firebase-admin": "^13.5.0", "fast-xml-parser": "^5.3.2",
"graphql": "^16.11.0", "firebase-admin": "^13.6.0",
"graphql": "^16.12.0",
"graphql-request": "^6.1.0", "graphql-request": "^6.1.0",
"intuit-oauth": "^4.2.0", "intuit-oauth": "^4.2.2",
"ioredis": "^5.8.1", "ioredis": "^5.8.2",
"json-2-csv": "^5.5.9", "json-2-csv": "^5.5.10",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"juice": "^11.0.3", "juice": "^11.0.3",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.30.1", "moment": "^2.30.1",
"moment-timezone": "^0.6.0", "moment-timezone": "^0.6.0",
"multer": "^1.4.5-lts.1", "multer": "^2.0.2",
"mustache": "^4.2.0",
"node-persist": "^4.0.4", "node-persist": "^4.0.4",
"nodemailer": "^6.10.0", "nodemailer": "^6.10.0",
"phone": "^3.1.67", "phone": "^3.1.67",
"query-string": "7.1.3", "query-string": "7.1.3",
"recursive-diff": "^1.0.9", "recursive-diff": "^1.0.9",
"rimraf": "^6.0.1", "rimraf": "^6.1.0",
"skia-canvas": "^3.0.8", "skia-canvas": "^3.0.8",
"soap": "^1.5.0", "soap": "^1.6.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.5", "socket.io-adapter": "^2.5.5",
"ssh2-sftp-client": "^11.0.0", "ssh2-sftp-client": "^11.0.0",
"twilio": "^5.10.2", "twilio": "^5.10.5",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"winston": "^3.18.3", "winston": "^3.18.3",
"winston-cloudwatch": "^6.3.0", "winston-cloudwatch": "^6.3.0",
"xml-formatter": "^3.6.7",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
"xmlbuilder2": "^3.1.1", "xmlbuilder2": "^4.0.0",
"yazl": "^3.3.1" "yazl": "^3.3.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.37.0", "@eslint/js": "^9.39.1",
"eslint": "^9.37.0", "eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0", "globals": "^15.15.0",
"mock-require": "^3.0.3", "mock-require": "^3.0.3",

View File

@@ -38,7 +38,7 @@ const { registerCleanupTask, initializeCleanupManager } = require("./server/util
const { loadEmailQueue } = require("./server/notifications/queues/emailQueue"); const { loadEmailQueue } = require("./server/notifications/queues/emailQueue");
const { loadAppQueue } = require("./server/notifications/queues/appQueue"); const { loadAppQueue } = require("./server/notifications/queues/appQueue");
const { SetLegacyWebsocketHandlers } = require("./server/web-sockets/web-socket");
const CLUSTER_RETRY_BASE_DELAY = 100; const CLUSTER_RETRY_BASE_DELAY = 100;
const CLUSTER_RETRY_MAX_DELAY = 5000; const CLUSTER_RETRY_MAX_DELAY = 5000;
const CLUSTER_RETRY_JITTER = 100; const CLUSTER_RETRY_JITTER = 100;
@@ -247,6 +247,29 @@ const connectToRedisCluster = async () => {
}); });
reject(err); reject(err);
}); });
redisCluster.on("+node", (node) => {
logger.log(`Redis cluster node added`, "INFO", "redis", "api", {
host: node.options.host,
port: node.options.port
});
});
redisCluster.on("-node", (node) => {
logger.log(`Redis cluster node removed`, "WARN", "redis", "api", {
host: node.options.host,
port: node.options.port
});
});
redisCluster.on("node error", (error, node) => {
console.dir(error);
logger.log(`Redis node error`, "ERROR", "redis", "api", {
host: node.options.host,
port: node.options.port,
message: error.message
});
});
}); });
}; };
@@ -324,6 +347,9 @@ const applySocketIO = async ({ server, app }) => {
} }
}); });
// Legacy Socket Events
SetLegacyWebsocketHandlers(io);
const api = { const api = {
pubClient, pubClient,
io, io,
@@ -387,9 +413,6 @@ const main = async () => {
const redisHelpers = applyRedisHelpers({ pubClient, app, logger }); const redisHelpers = applyRedisHelpers({ pubClient, app, logger });
const ioHelpers = applyIOHelpers({ app, redisHelpers, ioRedis, logger }); const ioHelpers = applyIOHelpers({ app, redisHelpers, ioRedis, logger });
// Legacy Socket Events
require("./server/web-sockets/web-socket");
// Initialize Queues // Initialize Queues
await loadQueues({ pubClient: pubClient, logger, redisHelpers, ioRedis }); await loadQueues({ pubClient: pubClient, logger, redisHelpers, ioRedis });

Some files were not shown because too many files have changed in this diff Show More