Compare commits

...

179 Commits

Author SHA1 Message Date
Allan Carr
a182ea0869 IO-3315 CDK InService Date
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-07-29 10:04:13 -07:00
Patrick Fic
d92bab113e Merged in hotfix/2025-07-22-SocketProvider (pull request #2430)
Hotfix/2025 07 22 SocketProvider
2025-07-22 21:24:59 +00:00
Patrick Fic
93c6e2b601 Revert socket transport settings. 2025-07-22 14:23:25 -07:00
Dave Richer
19a90571f6 Down Socket Reconnects 2025-07-22 17:11:52 -04:00
Dave Richer
953e70efef Merged in release/2025-07-18 (pull request #2422)
Release 2025-07-18 into master-AIO - IO-1054, IO-3252, IO-3286, IO-3291, IO-3296, IO-3303, IO-3309
2025-07-19 00:57:33 +00:00
Dave Richer
a6bae390e5 feature/IO-3255-simplified-parts-management - Merge release / resolve conflicts 2025-07-18 14:55:00 -04:00
Allan Carr
cf9d8d649d Merged in feature/IO-3309-Date-Restriction-Removal (pull request #2419)
IO-3309 Date Field Rescriton Removal

Approved-by: Dave Richer
2025-07-17 23:36:53 +00:00
Allan Carr
a25051c4c2 IO-3309 Date Field Rescriton Removal
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-07-17 16:31:08 -07:00
Allan Carr
d5c3152631 Merged in feature/IO-3252-Reschedule-Job (pull request #2417)
IO-3252 Reschedule Job with Existing Data

Approved-by: Dave Richer
2025-07-16 21:08:03 +00:00
Allan Carr
66c425bf96 IO-3252 Fix Spelling Mistake in Feature Request
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-07-16 14:12:16 -07:00
Allan Carr
ffad0dfbf7 IO-3252 Reschedule Job with Existing Data
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-07-16 14:02:30 -07:00
Patrick Fic
17285fc029 Merged in hotfix/2025-07-15-nginxtune (pull request #2416)
Remove all testing due to failed test in RO.
2025-07-16 19:07:20 +00:00
Patrick Fic
401e3cff73 Remove all testing due to failed test in RO. 2025-07-16 12:05:40 -07:00
Patrick Fic
865680e019 Merged in hotfix/2025-07-15-nginxtune (pull request #2415)
Adjust body and buffer sizes.
2025-07-15 21:07:46 +00:00
Patrick Fic
9f97ca0336 Adjust body and buffer sizes. 2025-07-15 14:06:55 -07:00
Patrick Fic
5df38f8612 Merged in hotfix/2025-07-15-nginxtune (pull request #2414)
Override nginx.conf
2025-07-15 20:46:27 +00:00
Patrick Fic
63c5719420 Override nginx.conf 2025-07-15 13:45:37 -07:00
Patrick Fic
d6c80f1420 Merged in hotfix/2025-07-15-nginxtune (pull request #2413)
Additional change
2025-07-15 20:36:13 +00:00
Patrick Fic
fade927c9e Additional change 2025-07-15 13:35:17 -07:00
Patrick Fic
9f472ce1d0 Merged in hotfix/2025-07-15-nginxtune (pull request #2412)
Add worker process limits for EB config.
2025-07-15 20:21:38 +00:00
Patrick Fic
47a56e32b9 Add worker process limits for EB config. 2025-07-15 13:20:41 -07:00
Allan Carr
f13f79acb6 Merged in feature/IO-3286-Additional-Product-Fruit-IDs (pull request #2406)
IO-3286 Additional Product Fruit IDs

Approved-by: Dave Richer
2025-07-15 18:01:05 +00:00
Dave Richer
bfa9fddb9e Merged in feature/IO-3303-logging (pull request #2410)
[DO NOT MERGE] - IO-3303 Logging/Socket Params into Master-AIO

Approved-by: Patrick Fic
2025-07-14 23:51:09 +00:00
Dave Richer
28abd9707e feature/IO-3303-logging - Logging 2025-07-14 19:31:31 -04:00
Dave Richer
5f621e1ae0 feature/IO-3303-logging - Logging 2025-07-14 19:28:29 -04:00
Dave Richer
624414799e Merge branch 'feature/IO-3303-Socket-IO-Optimization-Auto-Add-Watchers-Gate' into release/2025-07-18
# Conflicts:
#	client/src/contexts/SocketIO/socketProvider.jsx
2025-07-14 18:45:34 -04:00
Dave Richer
72091e9eae feature/IO-3303-Socket-IO-Optimization-Auto-Add-Watchers-Gate - SocketIO Optimization / Auto Add Watchers Gate 2025-07-14 18:42:27 -04:00
Dave Richer
9cfacdd025 Merged in feature/IO-3291-Tasks-Notifications (pull request #2407)
feature/IO-3291-Tasks-Notifications: Package Bumps
2025-07-14 17:34:30 +00:00
Dave Richer
d5c63b798a feature/IO-3291-Tasks-Notifications: Package Bumps 2025-07-14 13:33:23 -04:00
Allan Carr
655e516246 IO-3286 Additional Product Fruit IDs
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-07-11 11:07:12 -07:00
Dave Richer
7b12f0a3b9 Merged in feature/IO-3291-Tasks-Notifications (pull request #2404)
Feature/IO-3291 Tasks Notifications
2025-07-11 17:40:06 +00:00
Dave Richer
e0b937474d feature/IO-3291-Tasks-Notifications: Final 2025-07-11 13:38:19 -04:00
Allan Carr
5c4267f3ef Merge branch 'master-AIO' into feature/IO-3286-Additional-Product-Fruit-IDs 2025-07-11 10:31:22 -07:00
Dave Richer
4dcfb382a9 feature/IO-3291-Tasks-Notifications: Checkpoint 2025-07-10 17:27:32 -04:00
Dave Richer
cf181dfd0a feature/IO-3291-Tasks-Notifications: Checkpoint 2025-07-10 16:40:55 -04:00
Dave Richer
1127864ba9 feature/IO-3291-Tasks-Notifications: Checkpoint 2025-07-10 13:18:01 -04:00
Dave Richer
79e379b61a feature/IO-3291-Tasks-Notifications: Checkpoint 2025-07-10 10:12:50 -04:00
Allan Carr
e79e512291 Merged in feature/IO-3296-Scheduled-Delivery-Dashboard (pull request #2402)
Feature/IO-3296 Scheduled Delivery Dashboard

Approved-by: Dave Richer
2025-07-09 22:30:29 +00:00
Allan Carr
f0064abfbe IO-3296 Schedule Delivery Dashboard
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-07-09 15:16:31 -07:00
Dave Richer
4a30a5bc64 feature/IO-3291-Tasks-Notifications: Checkpoint 2025-07-09 17:47:32 -04:00
Allan Carr
32bdea559e IO-3296 Schedule Delivery Dashboard
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-07-09 14:37:51 -07:00
Dave Richer
d4215b7aee feature/IO-3291-Tasks-Notifications: Checkpoint 2025-07-09 16:41:55 -04:00
Dave Richer
2494399993 feature/IO-3291-Tasks-Notifications: Checkpoint 2025-07-09 13:12:28 -04:00
Dave Richer
34f62a8858 feature/IO-3291-Tasks-Notifications: Checkpoint 2025-07-09 12:40:26 -04:00
Dave Richer
9e5689b06f feature/IO-3291-Tasks-Notifications: Checkpoint 2025-07-09 11:59:57 -04:00
Dave Richer
5d69d37db2 Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3291-Tasks-Notifications 2025-07-09 11:14:28 -04:00
Dave Richer
9ab2fdc868 feature/IO-3291-Tasks-Notifications: Checkpoint 2025-07-09 11:14:04 -04:00
Patrick Fic
fbd6766dcd Merged in feature/IO-3294-imgproxy-bill-upload-hotfix (pull request #2401)
IO-3294 fix upload to image proxy for bills.
2025-07-08 21:46:35 +00:00
Patrick Fic
9ace531edb IO-3294 fix upload to image proxy for bills. 2025-07-08 14:31:32 -07:00
Dave Richer
2e3944099b feature/IO-3291-Tasks-Notifications: Checkpoint 2025-07-08 13:52:59 -04:00
Dave Richer
9b53bd9b40 feature/IO-3291-Tasks-Notifications: Checkpoint 2025-07-08 12:29:23 -04:00
Dave Richer
443ed717cb feature/IO-3291-Tasks-Notifications: Checkpoint 2025-07-08 11:38:18 -04:00
Dave Richer
9845c1cea5 feature/IO-3291-Tasks-Notifications: Checkpoint 2025-07-07 18:10:40 -04:00
Dave Richer
2061a49e0e feature/IO-3291-Tasks-Notifications: Checkpoint 2025-07-07 17:35:00 -04:00
Dave Richer
f8a3d0f854 feature/IO-3291-Tasks-Notifications: Pre-cleanup 2025-07-07 15:06:37 -04:00
Allan Carr
23901c0cc1 IO-3286 Adjustment to ID
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-07-07 08:36:16 -07:00
Allan Carr
b99a212d75 Merged in feature/IO-3286-Additional-Product-Fruit-IDs (pull request #2398)
IO-3286 Adjustment to ID

Approved-by: Dave Richer
2025-07-07 15:33:46 +00:00
Dave Richer
a4963922da Merged in feature/IO-1054-ScoreBoard-WorkingDays (pull request #2396)
feature/IO-1054-ScoreBoard-WorkingDays - Fix
2025-07-07 14:37:33 +00:00
Dave Richer
3ae41b7016 feature/IO-1054-ScoreBoard-WorkingDays - Fix 2025-07-07 10:36:58 -04:00
Dave Richer
9c59fd4c00 Merged in release/2025-07-04 (pull request #2393)
DO NOT MERGE - PENDING Release/2025 07 04  into master-AIO - IO-3284 IO-3285 IO-3286 IO-3288
2025-07-05 01:21:35 +00:00
Allan Carr
a9f959cced Merged in feature/IO-3286-Additional-Product-Fruit-IDs (pull request #2394)
IO-3286 Additional Product Fruit IDs

Approved-by: Dave Richer
2025-07-04 21:03:37 +00:00
Allan Carr
414897bba0 IO-3286 Additional Product Fruit IDs
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-07-04 13:35:32 -07:00
Allan Carr
7467a31d76 Merged in feature/IO-3284-JobLine-Price-Lbr-Hrs-Totals (pull request #2387)
IO-3284 JobLine Price Lbr Hrs Total

Approved-by: Dave Richer
2025-06-27 18:24:16 +00:00
Allan Carr
894f6bf6d2 Merged in feature/IO-3285-Shop-Config-Lite-Basic (pull request #2388)
IO-3285 Shop Config Lite Basic

Approved-by: Dave Richer
2025-06-27 18:23:41 +00:00
Allan Carr
744dfa8163 Merged in feature/IO-3286-Additional-Product-Fruit-IDs (pull request #2389)
IO-3286 Additional Product Fruit IDs

Approved-by: Dave Richer
2025-06-27 18:22:56 +00:00
Dave Richer
2293119518 Merged in feature/IO-3288-Score-Board-Blocked-Days (pull request #2390)
feature/IO-3288-Score-Board-Blocked-Days - Fix
2025-06-27 17:57:25 +00:00
Dave Richer
bd529a0dfa feature/IO-3288-Score-Board-Blocked-Days - Fix 2025-06-27 13:56:18 -04:00
Allan Carr
57ad89747f IO-3286 Additional Product Fruit IDs
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-06-26 18:48:09 -07:00
Allan Carr
3ae8f38adb IO-3285 Shop Config Lite Basic
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-06-26 18:45:48 -07:00
Allan Carr
dc5ed1a39c IO-3284 JobLine Price Lbr Hrs Total
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-06-26 18:40:38 -07:00
Patrick Fic
aa6e6b8980 Merged in hotfix/2025-06-25 (pull request #2386)
IO-3281 Resolve key issue for downloads.
2025-06-25 23:33:37 +00:00
Patrick Fic
1dc80c068b Merged in feature/IO-3281-imgproxy-download-hotfix (pull request #2385)
IO-3281 Resolve key issue for downloads.
2025-06-25 23:33:14 +00:00
Patrick Fic
bd0c4ceae2 IO-3281 Resolve key issue for downloads. 2025-06-25 16:32:47 -07:00
Patrick Fic
30b58c6ea5 Merged in hotfix/2025-06-25 (pull request #2384)
IO-3281 missed file in previous commit.
2025-06-25 22:48:53 +00:00
Patrick Fic
a55e9224f8 Merged in feature/IO-3281-imgproxy-download-hotfix (pull request #2383)
IO-3281 missed file in previous commit.
2025-06-25 22:48:41 +00:00
Patrick Fic
0c80abb3ca IO-3281 missed file in previous commit. 2025-06-25 15:48:06 -07:00
Patrick Fic
7137e611cd Merged in hotfix/2025-06-25 (pull request #2382)
IO-3281 Prevent broken stream reseting HTTP headers.
2025-06-25 22:37:01 +00:00
Patrick Fic
6f9d291d36 Merged in feature/IO-3281-imgproxy-download-hotfix (pull request #2381)
IO-3281 Prevent broken stream reseting HTTP headers.
2025-06-25 22:36:47 +00:00
Patrick Fic
f2a2653eae IO-3281 Prevent broken stream reseting HTTP headers. 2025-06-25 15:36:03 -07:00
Patrick Fic
73c25ab91f Merged in hotfix/2025-06-25 (pull request #2380)
Hotfix/2025 06 25 - IO-3279 IO-3281
2025-06-25 17:08:57 +00:00
Patrick Fic
780449bac6 Merge branch 'hotfix/2025-06-25' of bitbucket.org:snaptsoft/bodyshop into hotfix/2025-06-25 2025-06-25 10:08:09 -07:00
Patrick Fic
2509a1ecf3 Merge branch 'feature/IO-3279-usage-report' into hotfix/2025-06-25 2025-06-25 10:08:02 -07:00
Patrick Fic
16075f7ddd Merged in feature/IO-3281-imgproxy-download-hotfix (pull request #2378)
IO-3281 Adjust zip to stream.
2025-06-25 16:43:29 +00:00
Patrick Fic
27d28e7ffc IO-3281 Adjust zip to stream. 2025-06-25 09:42:45 -07:00
Patrick Fic
66b87e5c45 Merged in feature/IO-3281-imgproxy-download-hotfix (pull request #2376)
IO-3281 resolve imgproxy download failures.
2025-06-25 15:53:33 +00:00
Patrick Fic
c1e1dff7d2 IO-3281 resolve imgproxy download failures. 2025-06-25 08:51:41 -07:00
Dave Richer
f76eb7abf5 Merged in hotfix/IO-3280-Image-Selection-Bug (pull request #2374)
hotfix/IO-3280-Image-Selection-Bug - Fix Bug in image selection dialog
2025-06-24 18:43:29 +00:00
Dave Richer
25ea2a80a3 hotfix/IO-3280-Image-Selection-Bug - Fix Bug in image selection dialog 2025-06-24 14:42:06 -04:00
Patrick Fic
633d5668f0 IO-3279 Set usage report to 1 year. 2025-06-23 15:08:36 -07:00
Dave Richer
00cc47553b Merged in release/2025-06-13 (pull request #2373)
Release/2025 06 13 into master-AIO - IO-3222 IO-3256 IO-3254
2025-06-19 18:26:08 +00:00
Dave Richer
3c360130a3 Merged in feature/IO-3258-Shop-User-Vendor-Creation (pull request #2371)
Feature/IO-3258 Shop User Vendor Creation
2025-06-09 22:44:01 +00:00
Dave Richer
13e4143eeb feature/IO-3258-Shop-User-Vendor-Creation: Finish 2025-06-09 18:43:15 -04:00
Dave Richer
68c7b184d2 feature/IO-3258-Shop-User-Vendor-Creation: Finish 2025-06-09 18:39:29 -04:00
Dave Richer
9b85d15ff1 feature/IO-3258-Shop-User-Vendor-Creation: bump deps 2025-06-09 11:18:08 -04:00
Dave Richer
e7cf49a2ec Merged in feature/IO-3254-Basic-Lite-Trial-Completion-User-Lockout (pull request #2369)
feature/IO-3254-Basic-Lite-Trial-Completion-User-Lockout - Translations
2025-06-09 15:11:50 +00:00
Dave Richer
04b29b6970 feature/IO-3254-Basic-Lite-Trial-Completion-User-Lockout - Translations 2025-06-09 11:10:54 -04:00
Dave Richer
f5bc79cba7 Merged in feature/IO-3254-Basic-Lite-Trial-Completion-User-Lockout (pull request #2367)
feature/IO-3254-Basic-Lite-Trial-Completion-User-Lockout - Bump deps
2025-06-05 17:25:00 +00:00
Dave Richer
2ae18681cb feature/IO-3254-Basic-Lite-Trial-Completion-User-Lockout - Bump deps 2025-06-05 13:23:52 -04:00
Allan Carr
fda763476a IO-3256 Product Fruits IDs
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-06-04 17:34:23 -07:00
Dave Richer
999cbd80f4 Merged in feature/IO-3222-Vendor-Name-OpenSearch (pull request #2365)
IO-3222 Vendor Name Open Search
2025-06-03 18:11:06 +00:00
Dave Richer
ad2a5fe95b Merged in release/2025-06-02 (pull request #2364)
Release 2025-06-02 into master-AIO - IO-3075, IO-3105, IO-3182, IO-3214, IO-3230, IO-3235, IO-3236, IO-3239, IO-3243, IO-3246, IO-3247, IO-3249, IO-3251
2025-06-02 23:58:37 +00:00
Patrick Fic
d835021069 IO-3092 Resolve imgproxy limit fetch. 2025-06-02 11:03:58 -07:00
Dave Richer
c4b303aee1 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2362)
feature/IO-3182-Phone-Number-Consent - Checkpoint
2025-05-30 18:14:52 +00:00
Dave Richer
e2c5a4cba4 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-30 14:14:05 -04:00
Dave Richer
fd04125ed1 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2360)
feature/IO-3182-Phone-Number-Consent - Checkpoint
2025-05-29 17:51:45 +00:00
Dave Richer
a0566e76ab feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-29 13:49:56 -04:00
Patrick Fic
87e8b2ce27 Merged in feature/IO-3239-integration-logging (pull request #2359)
IO-3239 Additional logging fixes.
2025-05-28 22:22:09 +00:00
Patrick Fic
d52426f5f5 IO-3239 Additional logging fixes. 2025-05-28 15:21:42 -07:00
Allan Carr
5e24404e82 Merged in feature/IO-3251-Quick-Intake-Jobs-at-Change (pull request #2357)
IO-3251 Quick Intake Jobs at Change

Approved-by: Dave Richer
2025-05-28 20:25:37 +00:00
Allan Carr
64a280b111 IO-3251 Quick Intake Jobs at Change
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-05-28 13:26:13 -07:00
Dave Richer
cf393e8f9e Merged in feature/IO-3182-Phone-Number-Consent (pull request #2355)
feature/IO-3182-Phone-Number-Consent - Checkpoint
2025-05-28 18:02:19 +00:00
Dave Richer
909a21023a feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-28 14:01:24 -04:00
Dave Richer
0402156b4d Merged in feature/IO-3182-Phone-Number-Consent (pull request #2353)
Feature/IO-3182 Phone Number Consent
2025-05-28 17:46:42 +00:00
Dave Richer
94bdc6c43f feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-28 13:45:12 -04:00
Dave Richer
9466d36e69 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-28 13:17:21 -04:00
Dave Richer
412efb06e5 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-28 13:07:11 -04:00
Dave Richer
da7e637183 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-28 12:26:48 -04:00
Dave Richer
2e95fa25af feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-28 12:17:43 -04:00
Dave Richer
f6c63bbd74 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2351)
feature/IO-3182-Phone-Number-Consent - Checkpoint
2025-05-27 15:43:03 +00:00
Dave Richer
0a654082c2 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-27 11:41:31 -04:00
Dave Richer
2c20b731d2 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2350)
Feature/IO-3182 Phone Number Consent
2025-05-27 15:38:56 +00:00
Dave Richer
8a22897cdd feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-27 11:35:16 -04:00
Dave Richer
677da61b52 Merge remote-tracking branch 'origin/release/2025-06-02' into feature/IO-3182-Phone-Number-Consent 2025-05-27 11:34:44 -04:00
Dave Richer
6513434bd7 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-27 11:34:16 -04:00
Dave Richer
fe2600029f feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-27 11:03:09 -04:00
Allan Carr
c5b4efedfb Merged in feature/IO-3249-Delete-Job-Watchers (pull request #2348)
IO-3249 Delete Job Watchers

Approved-by: Dave Richer
2025-05-26 20:28:56 +00:00
Allan Carr
310321d0ab IO-3249 Delete Job Watchers
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-05-26 12:49:58 -07:00
Allan Carr
7e884c42ea Merged in feature/IO-3214-Extend-New-Fields-to-Audit-Log (pull request #2346)
IO-3214 Extend New Fields to Audit Log

Approved-by: Dave Richer
2025-05-26 19:10:16 +00:00
Allan Carr
e279bf41a4 IO-3214 Extend New Fields to Audit Log
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-05-26 12:11:26 -07:00
Dave Richer
4a060ab51c Merged in feature/IO-3182-Phone-Number-Consent (pull request #2345)
Feature/IO-3182 Phone Number Consent
2025-05-26 19:08:47 +00:00
Dave Richer
62c1c77a18 feature/IO-3182-Phone-Number-Consent - Finish core functionality 2025-05-26 15:07:57 -04:00
Dave Richer
db19ecb28c feature/IO-3182-Phone-Number-Consent - Front/Back Start/stop logic complete 2025-05-26 14:49:02 -04:00
Dave Richer
51748ce28d feature/IO-3182-Phone-Number-Consent - Front/Back Start/stop logic complete 2025-05-26 14:44:27 -04:00
Allan Carr
4bbfd8a9da Merged in feature/IO-3247-Quick-Deliver-Require-Delivery (pull request #2343)
IO-3247 Quick Deliver Require Delivery

Approved-by: Dave Richer
2025-05-26 17:37:36 +00:00
Allan Carr
d4d2db2cac IO-3247 Quick Deliver Require Delivery
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-05-26 10:29:03 -07:00
Dave Richer
23483144e1 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2341)
feature/IO-3182-Phone-Number-Consent - Up Deps
2025-05-26 17:09:48 +00:00
Dave Richer
67d5dcb062 feature/IO-3182-Phone-Number-Consent - Up Deps 2025-05-26 13:09:07 -04:00
Allan Carr
901a49e571 Merged in feature/IO-3246-Remote-Assist (pull request #2337)
IO-3246 Remote Assist

Approved-by: Dave Richer
2025-05-24 17:29:38 +00:00
Dave Richer
49ae107fde release/2025-06-02 - add phone 2025-05-24 13:23:38 -04:00
Dave Richer
0135281bcd release/2025-06-02 - test push 2025-05-24 13:15:53 -04:00
Allan Carr
99cf95daf0 IO-3246 Remote Assist
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-05-23 16:45:24 -07:00
Allan Carr
8c1758ae49 Merged in feature/IO-3075-Crisp-Basic-Info (pull request #2335)
IO-3075 Crisp Basic Info

Approved-by: Dave Richer
2025-05-23 18:03:15 +00:00
Allan Carr
2d764921ff IO-3075 Crisp Basic Info
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-05-23 10:43:15 -07:00
Dave Richer
858a11f8b4 Merged in feature/IO-3242-Visual-Production-Board-Vertical-Drag-Bug (pull request #2334)
HOTFIX - feature/IO-3242-Visual-Production-Board-Vertical-Drag-Bug - Fix bug
2025-05-23 15:38:17 +00:00
Dave Richer
4859239f55 Merged in feature/IO-3242-Visual-Production-Board-Vertical-Drag-Bug (pull request #2332)
feature/IO-3242-Visual-Production-Board-Vertical-Drag-Bug - Fix bug
2025-05-23 15:01:44 +00:00
Dave Richer
5c64d7185e feature/IO-3242-Visual-Production-Board-Vertical-Drag-Bug - Fix bug 2025-05-23 11:00:21 -04:00
Patrick Fic
152479bc08 Merged in feature/IO-3239-integration-logging (pull request #2331)
Feature/IO-3239 integration logging
2025-05-22 18:56:05 +00:00
Patrick Fic
2c508cf1a1 IO-3239 QBO Logging and integration log schema changes. 2025-05-22 11:54:17 -07:00
Patrick Fic
16a91c772a Merged in release/2025-06-02 (pull request #2330)
Release/2025 06 02
2025-05-22 16:46:27 +00:00
Dave Richer
5c47088b11 release/2025-06-02 - Lint Updates 2025-05-22 11:37:47 -04:00
Allan Carr
8e5dc4fa71 Merged in feature/IO-3243-Job-Costing-TOW (pull request #2327)
IO-3243 Job Costing TOW

Approved-by: Dave Richer
2025-05-22 15:13:34 +00:00
Allan Carr
39c3729f6d Merged in feature/IO-3230-Customer-List-Excel (pull request #2326)
IO-3230 Customer List Excel

Approved-by: Dave Richer
2025-05-22 15:13:03 +00:00
Allan Carr
e3d854e02b IO-3243 Job Costing TOW
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-05-21 17:53:33 -07:00
Allan Carr
618acf2acf IO-3230 Customer List Excel
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-05-21 16:28:09 -07:00
Dave Richer
2cf2b70293 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2325)
Feature/IO-3182 Phone Number Consent
2025-05-21 19:17:52 +00:00
Dave Richer
0541afceb8 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-21 15:17:11 -04:00
Dave Richer
28ed3f9936 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2324)
DO NOT MERGE JUST USING TO UNDO
2025-05-21 19:03:58 +00:00
Dave Richer
6afa50332b feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-21 15:03:02 -04:00
Dave Richer
8c8c68867d feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-21 14:39:17 -04:00
Dave Richer
8ee52598e8 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-21 14:32:35 -04:00
Allan Carr
c822028174 Merged in feature/IO-3235-FeatureAccess-VisualBoard-Color (pull request #2322)
IO-3235 FeatureAccess on VisualBoard for SmartSchedule Option of Color Cards

Approved-by: Dave Richer
2025-05-21 18:06:04 +00:00
Allan Carr
36b82c6195 Merged in feature/IO-3236-HasFeatureAccess-Date (pull request #2323)
IO-3236 HasFeatureAccess Date

Approved-by: Dave Richer
2025-05-21 18:05:23 +00:00
Allan Carr
831802f5af IO-3235 FeatureAccess on VisualBoard for SmartSchedule Option of Color Cards
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-05-20 15:25:29 -07:00
Dave Richer
7bd5190bf2 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-20 18:19:39 -04:00
Dave Richer
83860152a9 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-20 16:04:36 -04:00
Dave Richer
1e10493615 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2321)
Feature/IO-3182 Phone Number Consent
2025-05-20 17:45:24 +00:00
Dave Richer
9d81c68a4d feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-20 13:14:05 -04:00
Dave Richer
985d066978 feature/IO-3182-Phone-Number-Consent - Finish Database changes 2025-05-20 12:49:32 -04:00
Dave Richer
6ad9e27d1d feature/IO-3182-Phone-Number-Consent - Merge master / bump deps 2025-05-20 12:38:31 -04:00
Dave Richer
19ebdda5b3 Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3182-Phone-Number-Consent 2025-05-20 12:30:15 -04:00
Allan Carr
4602dd1183 Merged in master-AIO (pull request #2320)
IO-3217 OTSL Labor Type
2025-05-19 21:03:00 +00:00
Patrick Fic
6d59e3994f Merged in feature/IO-3105-qbo-version-update (pull request #2318)
feature/IO-3105-qbo-version-update

Approved-by: Patrick Fic
2025-05-19 19:30:34 +00:00
Patrick Fic
f770b2f1b1 IO-3105 Add QBO Minor Version. 2025-05-19 12:28:50 -07:00
Patrick Fic
b014744940 IO-3239 Add integration log statements on QBO. 2025-05-19 11:10:02 -07:00
Dave Richer
714c90c25e Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3182-Phone-Number-Consent 2025-05-15 18:55:23 -04:00
Dave Richer
9a3a971da6 Clear Stage 2025-05-15 18:55:20 -04:00
Dave Richer
96cba0aaab Clear Stage 2025-05-15 18:54:55 -04:00
Allan Carr
d4f718c44c Merged in feature/IO-3210-Podium-Datapump (pull request #2314)
IO-3210 Podium Datapump

Approved-by: Patrick Fic
2025-05-15 22:39:28 +00:00
Allan Carr
3fe0e3a33c IO-3222 Vendor Name Open Search
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-25 09:39:20 -07:00
170 changed files with 12246 additions and 7680 deletions

2
.gitignore vendored
View File

@@ -130,3 +130,5 @@ test-output.txt
server/job/test/fixtures server/job/test/fixtures
.github .github
_reference/ragmate/.ragmate.env
docker_data

View File

@@ -1,2 +1,2 @@
client_max_body_size 50M; client_max_body_size 50M;
client_body_buffer_size 5M; client_body_buffer_size 5M;

View File

@@ -12791,27 +12791,6 @@
</translation> </translation>
</translations> </translations>
</concept_node> </concept_node>
<concept_node>
<name>allow_text_message</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node> <concept_node>
<name>checklist</name> <name>checklist</name>
<definition_loaded>false</definition_loaded> <definition_loaded>false</definition_loaded>
@@ -42614,27 +42593,6 @@
</translation> </translation>
</translations> </translations>
</concept_node> </concept_node>
<concept_node>
<name>allow_text_message</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node> <concept_node>
<name>name</name> <name>name</name>
<definition_loaded>false</definition_loaded> <definition_loaded>false</definition_loaded>

689
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,19 +12,19 @@
"@apollo/client": "^3.13.6", "@apollo/client": "^3.13.6",
"@emotion/is-prop-valid": "^1.3.1", "@emotion/is-prop-valid": "^1.3.1",
"@fingerprintjs/fingerprintjs": "^4.6.1", "@fingerprintjs/fingerprintjs": "^4.6.1",
"@firebase/analytics": "^0.10.13", "@firebase/analytics": "^0.10.16",
"@firebase/app": "^0.12.1", "@firebase/app": "^0.13.1",
"@firebase/auth": "^1.10.2", "@firebase/auth": "^1.10.6",
"@firebase/firestore": "^4.7.12", "@firebase/firestore": "^4.7.17",
"@firebase/messaging": "^0.12.18", "@firebase/messaging": "^0.12.21",
"@jsreport/browser-client": "^3.1.0", "@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.8.1", "@reduxjs/toolkit": "^2.8.2",
"@sentry/cli": "^2.45.0", "@sentry/cli": "^2.47.1",
"@sentry/react": "^9.18.0", "@sentry/react": "^9.38.0",
"@sentry/vite-plugin": "^3.4.0", "@sentry/vite-plugin": "^3.5.0",
"@splitsoftware/splitio-react": "^2.1.1", "@splitsoftware/splitio-react": "^2.3.1",
"@tanem/react-nprogress": "^5.0.53", "@tanem/react-nprogress": "^5.0.53",
"antd": "^5.25.1", "antd": "^5.25.4",
"apollo-link-logger": "^2.0.1", "apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^4.3.0", "apollo-link-sentry": "^4.3.0",
"autosize": "^6.0.1", "autosize": "^6.0.1",
@@ -41,24 +41,25 @@
"i18next": "^24.2.3", "i18next": "^24.2.3",
"i18next-browser-languagedetector": "^8.1.0", "i18next-browser-languagedetector": "^8.1.0",
"immutability-helper": "^3.1.1", "immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.8", "libphonenumber-js": "^1.12.10",
"logrocket": "^9.0.2", "logrocket": "^9.0.2",
"markerjs2": "^2.32.4", "markerjs2": "^2.32.4",
"memoize-one": "^6.0.0", "memoize-one": "^6.0.0",
"normalize-url": "^8.0.1", "normalize-url": "^8.0.2",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"phone": "^3.1.59",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"query-string": "^9.1.2", "query-string": "^9.2.0",
"raf-schd": "^4.0.3", "raf-schd": "^4.0.3",
"react": "^18.3.1", "react": "^18.3.1",
"react-big-calendar": "^1.18.0", "react-big-calendar": "^1.19.2",
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-cookie": "^8.0.1", "react-cookie": "^8.0.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-drag-listview": "^2.0.0", "react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.1", "react-grid-gallery": "^1.0.1",
"react-grid-layout": "1.3.4", "react-grid-layout": "1.3.4",
"react-i18next": "^15.4.1", "react-i18next": "^15.5.2",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-image-lightbox": "^5.1.4", "react-image-lightbox": "^5.1.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@@ -69,7 +70,7 @@
"react-resizable": "^3.0.5", "react-resizable": "^3.0.5",
"react-router-dom": "^6.30.0", "react-router-dom": "^6.30.0",
"react-sticky": "^6.0.3", "react-sticky": "^6.0.3",
"react-virtuoso": "^4.12.7", "react-virtuoso": "^4.12.8",
"recharts": "^2.15.2", "recharts": "^2.15.2",
"redux": "^5.0.1", "redux": "^5.0.1",
"redux-actions": "^3.0.3", "redux-actions": "^3.0.3",
@@ -77,7 +78,7 @@
"redux-saga": "^1.3.0", "redux-saga": "^1.3.0",
"redux-state-sync": "^3.1.4", "redux-state-sync": "^3.1.4",
"reselect": "^5.1.1", "reselect": "^5.1.1",
"sass": "^1.88.0", "sass": "^1.89.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"styled-components": "^6.1.18", "styled-components": "^6.1.18",
"subscriptions-transport-ws": "^0.11.0", "subscriptions-transport-ws": "^0.11.0",
@@ -130,17 +131,17 @@
"@ant-design/icons": "^6.0.0", "@ant-design/icons": "^6.0.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.27.1",
"@dotenvx/dotenvx": "^1.44.0", "@dotenvx/dotenvx": "^1.47.5",
"@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.26.0", "@eslint/js": "^9.31.0",
"@playwright/test": "^1.51.1", "@playwright/test": "^1.54.1",
"@sentry/webpack-plugin": "^3.4.0", "@sentry/webpack-plugin": "^3.5.0",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.5.1",
"browserslist": "^4.24.5", "browserslist": "^4.25.0",
"browserslist-to-esbuild": "^2.1.1", "browserslist-to-esbuild": "^2.1.1",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"eslint": "^8.57.1", "eslint": "^8.57.1",
@@ -148,9 +149,9 @@
"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.17.1", "memfs": "^4.17.2",
"os-browserify": "^0.3.0", "os-browserify": "^0.3.0",
"playwright": "^1.51.1", "playwright": "^1.54.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",
@@ -160,7 +161,7 @@
"vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pwa": "^1.0.0", "vite-plugin-pwa": "^1.0.0",
"vite-plugin-style-import": "^2.0.0", "vite-plugin-style-import": "^2.0.0",
"vitest": "^3.1.3", "vitest": "^3.2.3",
"workbox-window": "^7.3.0" "workbox-window": "^7.3.0"
} }
} }

View File

@@ -2,10 +2,11 @@ import { useApolloClient, useMutation } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Checkbox, Form, Modal, Space } from "antd"; import { Button, Checkbox, Form, Modal, Space } from "antd";
import _ from "lodash"; import _ from "lodash";
import React, { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, 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 { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { INSERT_NEW_BILL } from "../../graphql/bills.queries"; import { INSERT_NEW_BILL } from "../../graphql/bills.queries";
import { UPDATE_INVENTORY_LINES } from "../../graphql/inventory.queries"; import { UPDATE_INVENTORY_LINES } from "../../graphql/inventory.queries";
import { UPDATE_JOB_LINE } from "../../graphql/jobs-lines.queries"; import { UPDATE_JOB_LINE } from "../../graphql/jobs-lines.queries";
@@ -24,7 +25,7 @@ import BillFormContainer from "../bill-form/bill-form.container";
import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility"; 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 { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
billEnterModal: selectBillEnterModal, billEnterModal: selectBillEnterModal,
@@ -53,10 +54,10 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
const notification = useNotification(); const notification = useNotification();
const { const {
treatments: { Enhanced_Payroll } treatments: { Enhanced_Payroll, Imgproxy }
} = useSplitTreatments({ } = useSplitTreatments({
attributes: {}, attributes: {},
names: ["Enhanced_Payroll"], names: ["Enhanced_Payroll", "Imgproxy"],
splitKey: bodyshop.imexshopid splitKey: bodyshop.imexshopid
}); });
@@ -196,7 +197,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
job: { lbr_adjustments: newAdjustments } job: { lbr_adjustments: newAdjustments }
} }
}); });
if (!!jobUpdate.errors) { if (jobUpdate.errors) {
notification["error"]({ notification["error"]({
message: t("jobs.errors.saving", { message: t("jobs.errors.saving", {
message: JSON.stringify(jobUpdate.errors) message: JSON.stringify(jobUpdate.errors)
@@ -213,7 +214,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
variables: { partsLineIds: markPolReceived.map((p) => p.id) }, variables: { partsLineIds: markPolReceived.map((p) => p.id) },
refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID"] refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID"]
}); });
if (!!r2.errors) { if (r2.errors) {
setLoading(false); setLoading(false);
setEnterAgain(false); setEnterAgain(false);
notification["error"]({ notification["error"]({
@@ -224,7 +225,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
} }
} }
if (!!r1.errors) { if (r1.errors) {
setLoading(false); setLoading(false);
setEnterAgain(false); setEnterAgain(false);
notification["error"]({ notification["error"]({
@@ -244,7 +245,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
consumedbybillid: billId consumedbybillid: billId
} }
}); });
if (!!r2.errors) { if (r2.errors) {
setLoading(false); setLoading(false);
setEnterAgain(false); setEnterAgain(false);
notification["error"]({ notification["error"]({
@@ -298,20 +299,39 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
}); });
}); });
} else { } else {
upload.forEach((u) => { //Check if using Imgproxy or cloudinary
handleUpload(
{ file: u.originFileObj }, if (Imgproxy.treatment === "on") {
{ upload.forEach((u) => {
bodyshop: bodyshop, handleUploadToImageProxy(
uploaded_by: currentUser.email, { file: u.originFileObj },
jobId: values.jobid, {
billId: billId, bodyshop: bodyshop,
tagsArray: null, uploaded_by: currentUser.email,
callback: null jobId: values.jobid,
}, billId: billId,
notification tagsArray: null,
); callback: null
}); },
notification
);
});
} else {
upload.forEach((u) => {
handleUpload(
{ file: u.originFileObj },
{
bodyshop: bodyshop,
uploaded_by: currentUser.email,
jobId: values.jobid,
billId: billId,
tagsArray: null,
callback: null
},
notification
);
});
}
} }
} }
/////////////////////////// ///////////////////////////
@@ -396,7 +416,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
{t("bills.labels.generatepartslabel")} {t("bills.labels.generatepartslabel")}
</Checkbox> </Checkbox>
<Button onClick={handleCancel}>{t("general.actions.cancel")}</Button> <Button onClick={handleCancel}>{t("general.actions.cancel")}</Button>
<Button loading={loading} onClick={() => form.submit()}> <Button loading={loading} onClick={() => form.submit()} id="save-bill-enter-modal">
{t("general.actions.save")} {t("general.actions.save")}
</Button> </Button>
{billEnterModal.context && billEnterModal.context.id ? null : ( {billEnterModal.context && billEnterModal.context.id ? null : (
@@ -406,6 +426,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
onClick={() => { onClick={() => {
setEnterAgain(true); setEnterAgain(true);
}} }}
id="save-and-new-bill-enter-modal"
> >
{t("general.actions.saveandnew")} {t("general.actions.saveandnew")}
</Button> </Button>

View File

@@ -1,5 +1,5 @@
import { Select } from "antd"; import { Select } from "antd";
import React, { forwardRef } from "react"; import { forwardRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import InstanceRenderMgr from "../../utils/instanceRenderMgr"; import InstanceRenderMgr from "../../utils/instanceRenderMgr";
@@ -43,7 +43,7 @@ const BillLineSearchSelect = ({ options, disabled, allowRemoved, ...restProps },
item.oem_partno ? ` - ${item.oem_partno}` : "" item.oem_partno ? ` - ${item.oem_partno}` : ""
}${item.alt_partno ? ` (${item.alt_partno})` : ""}`.trim(), }${item.alt_partno ? ` (${item.alt_partno})` : ""}`.trim(),
label: ( label: (
<div style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}> <div style={{ whiteSpace: "normal", wordBreak: "break-word" }}>
<span> <span>
{`${item.removed ? `(REMOVED) ` : ""}${item.line_desc}${ {`${item.removed ? `(REMOVED) ` : ""}${item.line_desc}${
item.oem_partno ? ` - ${item.oem_partno}` : "" item.oem_partno ? ` - ${item.oem_partno}` : ""

View File

@@ -1,6 +1,6 @@
import { EditFilled, SyncOutlined } from "@ant-design/icons"; import { EditFilled, SyncOutlined } from "@ant-design/icons";
import { Button, Card, Checkbox, Input, Space, Table } from "antd"; import { Button, Card, Checkbox, Input, Space, Table } from "antd";
import React, { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FaTasks } from "react-icons/fa"; import { FaTasks } from "react-icons/fa";
import { connect } from "react-redux"; import { connect } from "react-redux";
@@ -209,6 +209,7 @@ export function BillsListTableComponent({
} }
}); });
}} }}
id="reconcile-bills-button"
> >
<LockerWrapperComponent featureName="bills"> {t("jobs.actions.reconcile")}</LockerWrapperComponent> <LockerWrapperComponent featureName="bills"> {t("jobs.actions.reconcile")}</LockerWrapperComponent>
</Button> </Button>

View File

@@ -3,6 +3,7 @@ import { Button, Form, InputNumber, Popover, Space } from "antd";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
export default function CABCpvrtCalculator({ disabled, form }) { export default function CABCpvrtCalculator({ disabled, form }) {
const [visibility, setVisibility] = useState(false); const [visibility, setVisibility] = useState(false);
@@ -39,7 +40,7 @@ export default function CABCpvrtCalculator({ disabled, form }) {
); );
return ( return (
<Popover destroyTooltipOnHide content={popContent} open={visibility} disabled={disabled}> <Popover destroyOnHidden content={popContent} open={visibility} disabled={disabled}>
<Button disabled={disabled} onClick={() => setVisibility(true)}> <Button disabled={disabled} onClick={() => setVisibility(true)}>
<CalculatorFilled /> <CalculatorFilled />
</Button> </Button>

View File

@@ -34,16 +34,14 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) {
SubscribeToTopicForFCMNotification(); SubscribeToTopicForFCMNotification();
//Register WS handlers // Register WebSocket handlers
if (socket && socket.connected) { if (socket && socket.connected) {
registerMessagingHandlers({ socket, client }); registerMessagingHandlers({ socket, client });
}
return () => { return () => {
if (socket && socket.connected) {
unregisterMessagingHandlers({ socket }); unregisterMessagingHandlers({ socket });
} };
}; }
}, [bodyshop, socket, t, client]); }, [bodyshop, socket, t, client]);
if (!bodyshop || !bodyshop.messagingservicesid) return <></>; if (!bodyshop || !bodyshop.messagingservicesid) return <></>;

View File

@@ -202,8 +202,6 @@ export const registerMessagingHandlers = ({ socket, client }) => {
text: message.text text: message.text
}; };
// Add cases for other known message types as needed
default: default:
// Log a warning for unhandled message types // Log a warning for unhandled message types
logLocal("handleMessageChanged - Unhandled message type", { type: message.type }); logLocal("handleMessageChanged - Unhandled message type", { type: message.type });
@@ -211,7 +209,7 @@ export const registerMessagingHandlers = ({ socket, client }) => {
} }
} }
return messageRef; // Keep other messages unchanged return messageRef;
}); });
} }
} }
@@ -245,11 +243,8 @@ export const registerMessagingHandlers = ({ socket, client }) => {
}); });
const updatedList = existingList?.conversations const updatedList = existingList?.conversations
? [ ? [newConversation, ...existingList.conversations.filter((conv) => conv.id !== newConversation.id)]
newConversation, : [newConversation]; // Prevent duplicates
...existingList.conversations.filter((conv) => conv.id !== newConversation.id) // Prevent duplicates
]
: [newConversation];
client.cache.writeQuery({ client.cache.writeQuery({
query: CONVERSATION_LIST_QUERY, query: CONVERSATION_LIST_QUERY,
@@ -403,6 +398,7 @@ export const registerMessagingHandlers = ({ socket, client }) => {
} }
break; break;
default: default:
logLocal("handleConversationChanged - Unhandled type", { type }); logLocal("handleConversationChanged - Unhandled type", { type });
client.cache.modify({ client.cache.modify({
@@ -419,10 +415,95 @@ export const registerMessagingHandlers = ({ socket, client }) => {
} }
}; };
// Existing handler for phone number opt-out
const handlePhoneNumberOptedOut = async (data) => {
const { bodyshopid, phone_number } = data;
logLocal("handlePhoneNumberOptedOut - Start", data);
try {
client.cache.modify({
id: "ROOT_QUERY",
fields: {
phone_number_opt_out(existing = [], { readField }) {
const phoneNumberExists = existing.some(
(ref) => readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid
);
if (phoneNumberExists) {
logLocal("handlePhoneNumberOptedOut - Phone number already in cache", { phone_number, bodyshopid });
return existing;
}
const newOptOut = {
__typename: "phone_number_opt_out",
id: `temporary-${phone_number}-${Date.now()}`,
bodyshopid,
phone_number,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
return [...existing, newOptOut];
}
},
broadcast: true
});
client.cache.evict({
id: "ROOT_QUERY",
fieldName: "phone_number_opt_out",
args: { bodyshopid, search: phone_number }
});
client.cache.gc();
logLocal("handlePhoneNumberOptedOut - Cache updated successfully", data);
} catch (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 { bodyshopid, phone_number } = data;
logLocal("handlePhoneNumberOptedIn - Start", data);
try {
// Update the Apollo cache for GET_PHONE_NUMBER_OPT_OUTS by removing the phone number
client.cache.modify({
id: "ROOT_QUERY",
fields: {
phone_number_opt_out(existing = [], { readField }) {
// Filter out the phone number from the opt-out list
return existing.filter(
(ref) => !(readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid)
);
}
},
broadcast: true // Trigger UI updates
});
// Evict the cache entry to force a refetch on next query
client.cache.evict({
id: "ROOT_QUERY",
fieldName: "phone_number_opt_out",
args: { bodyshopid, search: phone_number }
});
client.cache.gc();
logLocal("handlePhoneNumberOptedIn - Cache updated successfully", data);
} catch (error) {
console.error("Error updating cache for phone number opt-in:", error);
logLocal("handlePhoneNumberOptedIn - Error", { error: error.message });
}
};
socket.on("new-message-summary", handleNewMessageSummary); socket.on("new-message-summary", handleNewMessageSummary);
socket.on("new-message-detailed", handleNewMessageDetailed); socket.on("new-message-detailed", handleNewMessageDetailed);
socket.on("message-changed", handleMessageChanged); socket.on("message-changed", handleMessageChanged);
socket.on("conversation-changed", handleConversationChanged); socket.on("conversation-changed", handleConversationChanged);
socket.on("phone-number-opted-out", handlePhoneNumberOptedOut);
socket.on("phone-number-opted-in", handlePhoneNumberOptedIn);
}; };
export const unregisterMessagingHandlers = ({ socket }) => { export const unregisterMessagingHandlers = ({ socket }) => {
@@ -431,4 +512,6 @@ export const unregisterMessagingHandlers = ({ socket }) => {
socket.off("new-message-detailed"); socket.off("new-message-detailed");
socket.off("message-changed"); socket.off("message-changed");
socket.off("conversation-changed"); socket.off("conversation-changed");
socket.off("phone-number-opted-out");
socket.off("phone-number-opted-in");
}; };

View File

@@ -1,5 +1,5 @@
import { Badge, Card, List, Space, Tag } from "antd"; import { Badge, Card, List, Space, Tag, Tooltip } from "antd";
import React, { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Virtuoso } from "react-virtuoso"; import { Virtuoso } from "react-virtuoso";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -9,36 +9,62 @@ import { TimeAgoFormatter } from "../../utils/DateFormatter";
import PhoneFormatter from "../../utils/PhoneFormatter"; import PhoneFormatter from "../../utils/PhoneFormatter";
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component"; import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import _ from "lodash"; import _ from "lodash";
import { ExclamationCircleOutlined } from "@ant-design/icons";
import "./chat-conversation-list.styles.scss"; import "./chat-conversation-list.styles.scss";
import { useQuery } from "@apollo/client";
import { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries.js";
import { phone } from "phone";
import { useTranslation } from "react-i18next";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation selectedConversation: selectSelectedConversation,
bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId)) setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId))
}); });
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) { function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) {
// That comma is there for a reason, do not remove it const { t } = useTranslation();
const [, forceUpdate] = useState(false); const [, forceUpdate] = useState(false);
// Re-render every minute const phoneNumbers = conversationList.map((item) => phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, ""));
const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUTS, {
variables: {
bodyshopid: bodyshop.id,
phone_numbers: phoneNumbers
},
skip: !conversationList.length,
fetchPolicy: "cache-and-network"
});
const optOutMap = useMemo(() => {
const map = new Map();
optOutData?.phone_number_opt_out?.forEach((optOut) => {
map.set(optOut.phone_number, true);
});
return map;
}, [optOutData?.phone_number_opt_out]);
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
forceUpdate((prev) => !prev); // Toggle state to trigger re-render forceUpdate((prev) => !prev);
}, 60000); // 1 minute in milliseconds }, 60000);
return () => clearInterval(interval);
return () => clearInterval(interval); // Cleanup on unmount
}, []); }, []);
// Memoize the sorted conversation list const sortedConversationList = useMemo(() => {
const sortedConversationList = React.useMemo(() => {
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 hasOptOutEntry = optOutMap.has(normalizedPhone);
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
@@ -60,7 +86,18 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
</> </>
); );
const cardExtra = <Badge count={item.messages_aggregate.aggregate.count} />; const cardExtra = (
<>
<Badge count={item.messages_aggregate.aggregate.count} />
{hasOptOutEntry && (
<Tooltip title={t("consent.text_body")}>
<Tag color="red" icon={<ExclamationCircleOutlined />}>
{t("messaging.labels.no_consent")}
</Tag>
</Tooltip>
)}
</>
);
const getCardStyle = () => const getCardStyle = () =>
item.id === selectedConversation item.id === selectedConversation
@@ -73,9 +110,25 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
onClick={() => setSelectedConversation(item.id)} onClick={() => setSelectedConversation(item.id)}
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()} bordered={false} 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>
); );
@@ -85,7 +138,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

@@ -24,7 +24,7 @@
/* Add spacing and better alignment for items */ /* Add spacing and better alignment for items */
.chat-list-item { .chat-list-item {
padding: 0.5rem 0; /* Add spacing between list items */ padding: 0.2rem 0; /* Add spacing between list items */
.ant-card { .ant-card {
border-radius: 8px; /* Slight rounding for card edges */ border-radius: 8px; /* Slight rounding for card edges */

View File

@@ -58,6 +58,7 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
userid userid
created_at created_at
read read
is_system
} }
`, `,
data: message data: message

View File

@@ -13,13 +13,14 @@ import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-document
import JobsDocumentImgproxyGalleryExternal from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component"; import JobsDocumentImgproxyGalleryExternal from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component";
import JobDocumentsLocalGalleryExternal from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component"; import JobDocumentsLocalGalleryExternal from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component"; import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import "./chat-media-selector.styles.scss";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) const mapDispatchToProps = (dispatch) => ({});
});
export default connect(mapStateToProps, mapDispatchToProps)(ChatMediaSelector); export default connect(mapStateToProps, mapDispatchToProps)(ChatMediaSelector);
export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, conversation }) { export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, conversation }) {
@@ -37,9 +38,8 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only", nextFetchPolicy: "network-only",
variables: { variables: {
jobId: conversation.job_conversations[0] && conversation.job_conversations[0].jobid jobId: conversation.job_conversations[0]?.jobid
}, },
skip: !open || !conversation.job_conversations || conversation.job_conversations.length === 0 skip: !open || !conversation.job_conversations || conversation.job_conversations.length === 0
}); });
@@ -56,25 +56,25 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
//If Imageproxy is on, rely only on the LMS selector //If Imageproxy is on, rely only on the LMS selector
//If not on, use the old methods. //If not on, use the old methods.
const content = ( const content = (
<div> <div className="media-selector-content">
{loading && <LoadingSpinner />} {loading && <LoadingSpinner />}
{error && <AlertComponent message={error.message} type="error" />} {error && <AlertComponent message={error.message} type="error" />}
{selectedMedia.filter((s) => s.isSelected).length >= 10 ? ( {selectedMedia.filter((s) => s.isSelected).length >= 10 ? (
<div style={{ color: "red" }}>{t("messaging.labels.maxtenimages")}</div> <div className="error-message">{t("messaging.labels.maxtenimages")}</div>
) : null} ) : null}
{Imgproxy.treatment === "on" ? ( {Imgproxy.treatment === "on" ? (
<> <>
{!bodyshop.uselocalmediaserver && ( {!bodyshop.uselocalmediaserver && (
<JobsDocumentImgproxyGalleryExternal <JobsDocumentImgproxyGalleryExternal
jobId={conversation.job_conversations[0].jobid} jobId={conversation.job_conversations[0]?.jobid}
externalMediaState={[selectedMedia, setSelectedMedia]} externalMediaState={[selectedMedia, setSelectedMedia]}
/> />
)} )}
{bodyshop.uselocalmediaserver && open && ( {bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal <JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]} externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid} jobId={conversation.job_conversations[0]?.jobid}
/> />
)} )}
</> </>
@@ -89,7 +89,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
{bodyshop.uselocalmediaserver && open && ( {bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal <JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]} externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid} jobId={conversation.job_conversations[0]?.jobid}
/> />
)} )}
</> </>
@@ -100,12 +100,17 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
return ( return (
<Popover <Popover
content={ content={
conversation.job_conversations.length === 0 ? <div>{t("messaging.errors.noattachedjobs")}</div> : content conversation.job_conversations.length === 0 ? (
<div className="no-jobs-message">{t("messaging.errors.noattachedjobs")}</div>
) : (
content
)
} }
title={t("messaging.labels.selectmedia")} title={t("messaging.labels.selectmedia")}
trigger="click" trigger="click"
open={open} open={open}
onOpenChange={handleVisibleChange} onOpenChange={handleVisibleChange}
classNames={{ root: "media-selector-popover" }}
> >
<Badge count={selectedMedia.filter((s) => s.isSelected).length}> <Badge count={selectedMedia.filter((s) => s.isSelected).length}>
<PictureFilled style={{ margin: "0 .5rem" }} /> <PictureFilled style={{ margin: "0 .5rem" }} />

View File

@@ -0,0 +1,48 @@
.media-selector-popover {
.ant-popover-inner-content {
position: relative;
max-width: 640px;
max-height: 480px;
overflow-y: auto;
padding: 8px;
background-color: #fff;
border-radius: 8px;
}
}
.media-selector-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.error-message {
color: red;
font-size: 12px;
text-align: center;
margin-bottom: 8px;
}
.no-jobs-message {
font-size: 14px;
color: #888;
text-align: center;
padding: 8px;
}
/* Style images within gallery components */
.media-selector-content img {
object-fit: cover;
border-radius: 4px;
margin: 4px;
cursor: pointer;
}
/* Grid layout for gallery components */
.media-selector-content .ant-image, /* Assuming gallery components use Ant Design's Image */
.media-selector-content .gallery-container { /* Fallback for custom gallery classes */
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 4px;
}

View File

@@ -4,13 +4,16 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
.archive-button { .archive-button {
height: 20px; height: 20px;
border-radius: 4px; border-radius: 4px;
} }
.chat-title { .chat-title {
margin-bottom: 5px; margin-bottom: 5px;
} }
.messages { .messages {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -37,11 +40,13 @@
gap: 8px; gap: 8px;
} }
} }
.chat-send-message-button{
.chat-send-message-button {
margin: 0.3rem; margin: 0.3rem;
padding-left: 0.5rem; padding-left: 0.5rem;
} }
.message-icon { .message-icon {
position: absolute; position: absolute;
bottom: 0.1rem; bottom: 0.1rem;
@@ -125,6 +130,37 @@
} }
} }
.system {
align-items: center;
margin: 0.5rem 10%;
.message {
background-color: #f5f5f5;
border-radius: 10px;
padding: 0.5rem 1rem;
text-align: center;
font-style: italic;
color: #555;
width: fit-content;
max-width: 80%;
}
.system-label {
font-size: 0.75rem;
color: #888;
margin-bottom: 0.2rem;
display: block;
}
.system-date {
font-size: 0.75rem;
color: #888;
margin-top: 0.2rem;
text-align: center;
}
}
.virtuoso-container { .virtuoso-container {
flex: 1; flex: 1;
overflow: auto; overflow: auto;

View File

@@ -2,17 +2,29 @@ import Icon from "@ant-design/icons";
import { Tooltip } from "antd"; import { Tooltip } from "antd";
import i18n from "i18next"; import i18n from "i18next";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import { MdDone, MdDoneAll } from "react-icons/md"; import { MdClose, MdDone, MdDoneAll } from "react-icons/md";
import { DateTimeFormatter } from "../../utils/DateFormatter"; import { DateTimeFormatter } from "../../utils/DateFormatter";
export const renderMessage = (messages, index) => { export const renderMessage = (messages, index) => {
const message = messages[index]; const message = messages[index];
const isSystem = message.is_system;
// Determine message class
const messageClass = isSystem ? "system messages" : message.isoutbound ? "mine messages" : "yours messages";
// Tooltip content based on message type
const tooltipTitle = isSystem ? (
i18n.t("consent.text_body")
) : (
<DateTimeFormatter>{message.created_at}</DateTimeFormatter>
);
return ( return (
<div key={index} className={`${message.isoutbound ? "mine messages" : "yours messages"}`}> <div key={index} className={messageClass}>
<div className="message msgmargin"> <div className="message msgmargin">
<Tooltip title={DateTimeFormatter({ children: message.created_at })}> <Tooltip title={tooltipTitle}>
<div> <div>
{isSystem && <span className="system-label">System</span>}
{/* Render images if available */} {/* Render images if available */}
{message.image && message.image_path?.length > 0 && ( {message.image && message.image_path?.length > 0 && (
<div className="message-images"> <div className="message-images">
@@ -26,20 +38,31 @@ export const renderMessage = (messages, index) => {
</div> </div>
)} )}
{/* Render text if available */} {/* Render text if available */}
{message.text && <div>{message.text}</div>} {message.text && <div className="message-text">{message.text}</div>}
{/* Render date for system messages */}
{isSystem && (
<div className="system-date">
<DateTimeFormatter>{message.created_at}</DateTimeFormatter>
</div>
)}
</div> </div>
</Tooltip> </Tooltip>
{/* Message status icons */} {/* Message status icons for non-system messages */}
{message.status && (message.status === "sent" || message.status === "delivered") && ( {!isSystem &&
<div className="message-status"> message.status &&
<Icon component={message.status === "sent" ? MdDone : MdDoneAll} className="message-icon" /> (message.status === "sent" || message.status === "delivered" || message.status === "failed") && (
</div> <div className="message-status">
)} <Icon
component={message.status === "sent" ? MdDone : message.status === "delivered" ? MdDoneAll : MdClose}
className="message-icon"
style={message.status === "failed" ? { color: "#ff0000" } : undefined}
/>
</div>
)}
</div> </div>
{/* Outbound message metadata for non-system messages */}
{/* Outbound message metadata */} {!isSystem && message.isoutbound && (
{message.isoutbound && (
<div style={{ fontSize: 10 }}> <div style={{ fontSize: 10 }}>
{i18n.t("messaging.labels.sentby", { {i18n.t("messaging.labels.sentby", {
by: message.userid, by: message.userid,

View File

@@ -1,6 +1,6 @@
import { LoadingOutlined, SendOutlined } from "@ant-design/icons"; import { ExclamationCircleOutlined, LoadingOutlined, SendOutlined } from "@ant-design/icons";
import { Input, Spin } from "antd"; import { Alert, Input, Space, Spin, Tooltip } from "antd";
import React, { useEffect, useRef, useState } from "react"; import { useEffect, useRef, 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";
@@ -10,6 +10,9 @@ import { selectIsSending, selectMessage } from "../../redux/messaging/messaging.
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import ChatMediaSelector from "../chat-media-selector/chat-media-selector.component"; import ChatMediaSelector from "../chat-media-selector/chat-media-selector.component";
import ChatPresetsComponent from "../chat-presets/chat-presets.component"; import ChatPresetsComponent from "../chat-presets/chat-presets.component";
import { useQuery } from "@apollo/client";
import { phone } from "phone";
import { GET_PHONE_NUMBER_OPT_OUT } from "../../graphql/phone-number-opt-out.queries";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -25,16 +28,24 @@ const mapDispatchToProps = (dispatch) => ({
function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSending, message, setMessage }) { function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSending, message, setMessage }) {
const inputArea = useRef(null); const inputArea = useRef(null);
const [selectedMedia, setSelectedMedia] = useState([]); const [selectedMedia, setSelectedMedia] = useState([]);
const { t } = useTranslation();
const normalizedPhone = phone(conversation.phone_num, "CA").phoneNumber.replace(/^\+1/, "");
const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUT, {
variables: { bodyshopid: bodyshop.id, phone_number: normalizedPhone },
fetchPolicy: "cache-and-network"
});
const isOptedOut = !!optOutData?.phone_number_opt_out?.[0];
useEffect(() => { useEffect(() => {
inputArea.current.focus(); inputArea.current.focus();
}, [isSending, setMessage]); }, [isSending, setMessage]);
const { t } = useTranslation();
const handleEnter = () => { const handleEnter = () => {
const selectedImages = selectedMedia.filter((i) => i.isSelected); const selectedImages = selectedMedia.filter((i) => i.isSelected);
if ((message === "" || !message) && selectedImages.length === 0) return; if ((message === "" || !message) && selectedImages.length === 0) return;
if (isOptedOut) return; // Prevent sending if phone number is opted out
logImEXEvent("messaging_send_message"); logImEXEvent("messaging_send_message");
if (selectedImages.length < 11) { if (selectedImages.length < 11) {
@@ -44,7 +55,8 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
messagingServiceSid: bodyshop.messagingservicesid, messagingServiceSid: bodyshop.messagingservicesid,
conversationid: conversation.id, conversationid: conversation.id,
selectedMedia: selectedImages, selectedMedia: selectedImages,
imexshopid: bodyshop.imexshopid imexshopid: bodyshop.imexshopid,
bodyshopid: bodyshop.id
}; };
sendMessage(newMessage); sendMessage(newMessage);
setSelectedMedia( setSelectedMedia(
@@ -56,47 +68,67 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
}; };
return ( return (
<div className="imex-flex-row" style={{ width: "100%" }}> <Space direction="vertical" style={{ width: "100%" }} size="middle">
<ChatPresetsComponent className="imex-flex-row__margin" /> {isOptedOut && (
<ChatMediaSelector <Tooltip title={t("consent.text_body")}>
conversation={conversation} <Alert
selectedMedia={selectedMedia} showIcon={true}
setSelectedMedia={setSelectedMedia} icon={<ExclamationCircleOutlined />}
/> message={t("messaging.errors.no_consent")}
<span style={{ flex: 1 }}> type="error"
<Input.TextArea
className="imex-flex-row__margin imex-flex-row__grow"
allowClear
autoFocus
ref={inputArea}
autoSize={{ minRows: 1, maxRows: 4 }}
value={message}
disabled={isSending}
placeholder={t("messaging.labels.typeamessage")}
onChange={(e) => setMessage(e.target.value)}
onPressEnter={(event) => {
event.preventDefault();
if (!!!event.shiftKey) handleEnter();
}}
/>
</span>
<SendOutlined
className="chat-send-message-button"
// disabled={message === "" || !message}
onClick={handleEnter}
/>
<Spin
style={{ display: `${isSending ? "" : "none"}` }}
indicator={
<LoadingOutlined
style={{
fontSize: 24
}}
spin
/> />
} </Tooltip>
/> )}
</div> <div className="imex-flex-row" style={{ width: "100%" }}>
{!isOptedOut && (
<>
<ChatPresetsComponent disabled={isSending} className="imex-flex-row__margin" />
<ChatMediaSelector
disabled={isSending}
conversation={conversation}
selectedMedia={selectedMedia}
setSelectedMedia={setSelectedMedia}
/>
</>
)}
<span style={{ flex: 1 }}>
<Input.TextArea
className="imex-flex-row__margin imex-flex-row__grow"
allowClear
autoFocus
ref={inputArea}
autoSize={{ minRows: 1, maxRows: 4 }}
value={message}
disabled={isSending || isOptedOut}
placeholder={t("messaging.labels.typeamessage")}
onChange={(e) => setMessage(e.target.value)}
onPressEnter={(event) => {
event.preventDefault();
if (!event.shiftKey && !isOptedOut) handleEnter();
}}
/>
</span>
{!isOptedOut && (
<SendOutlined
className="chat-send-message-button"
disabled={isSending || message === "" || !message}
onClick={handleEnter}
/>
)}
<Spin
style={{ display: `${isSending ? "" : "none"}` }}
indicator={
<LoadingOutlined
style={{
fontSize: 24
}}
spin
/>
}
/>
</div>
</Space>
); );
} }

View File

@@ -1,10 +1,10 @@
import React, { forwardRef, useEffect, useState } from "react"; import { forwardRef, useEffect, useState } from "react";
import { Select } from "antd"; import { Select } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const { Option } = Select; const { Option } = Select;
const ContractStatusComponent = ({ value, onChange }, ref) => { const ContractStatusComponent = ({ value, onChange }) => {
const [option, setOption] = useState(value); const [option, setOption] = useState(value);
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -1,5 +1,5 @@
import { Slider } from "antd"; import { Slider } from "antd";
import React, { forwardRef } from "react"; import { forwardRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const CourtesyCarFuelComponent = (props, ref) => { const CourtesyCarFuelComponent = (props, ref) => {

View File

@@ -0,0 +1,411 @@
import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined } from "@ant-design/icons";
import { Card, Space, Switch, Table, Tooltip, Typography } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { TimeFormatter } from "../../../utils/DateFormatter";
import { onlyUnique } from "../../../utils/arrayHelper";
import dayjs from "../../../utils/day";
import { alphaSort, dateSort } from "../../../utils/sorters";
import useLocalStorage from "../../../utils/useLocalStorage";
import ChatOpenButton from "../../chat-open-button/chat-open-button.component";
import OwnerNameDisplay, { OwnerNameDisplayFunction } from "../../owner-name-display/owner-name-display.component";
import DashboardRefreshRequired from "../refresh-required.component";
export default function DashboardScheduledDeliveryToday({ data, ...cardProps }) {
const { t } = useTranslation();
const [state, setState] = useState({
sortedInfo: {},
filteredInfo: {}
});
const [isTvModeScheduledDelivery, setIsTvModeScheduledDelivery] = useLocalStorage("isTvModeScheduledDelivery", false);
if (!data) return null;
if (!data.scheduled_delivery_today) return <DashboardRefreshRequired {...cardProps} />;
const scheduledDeliveryToday = data.scheduled_delivery_today.map((item) => {
const joblines_body = item.joblines
? item.joblines.filter((l) => l.mod_lbr_ty !== "LAR").reduce((acc, val) => acc + val.mod_lb_hrs, 0)
: 0;
const joblines_ref = item.joblines
? item.joblines.filter((l) => l.mod_lbr_ty === "LAR").reduce((acc, val) => acc + val.mod_lb_hrs, 0)
: 0;
return {
...item,
joblines_body,
joblines_ref
};
});
const tvFontSize = 18;
const tvFontWeight = "bold";
const tvColumns = [
{
title: t("jobs.fields.scheduled_delivery"),
dataIndex: "scheduled_delivery",
key: "scheduled_delivery",
ellipsis: true,
sorter: (a, b) => dateSort(a.scheduled_delivery, b.scheduled_delivery),
sortOrder: state.sortedInfo.columnKey === "scheduled_delivery" && state.sortedInfo.order,
render: (text, record) => (
<span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>
<TimeFormatter>{record.scheduled_delivery}</TimeFormatter>
</span>
)
},
{
title: t("jobs.fields.ro_number"),
dataIndex: "ro_number",
key: "ro_number",
sorter: (a, b) => alphaSort(a.ro_number, b.ro_number),
sortOrder: state.sortedInfo.columnKey === "ro_number" && state.sortedInfo.order,
render: (text, record) => (
<Link to={"/manage/jobs/" + record.jobid} onClick={(e) => e.stopPropagation()}>
<Space>
<span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>
{record.ro_number || t("general.labels.na")}
{record.production_vars && record.production_vars.alert ? (
<ExclamationCircleFilled className="production-alert" />
) : null}
{record.suspended && <PauseCircleOutlined style={{ color: "orangered" }} />}
{record.iouparent && (
<Tooltip title={t("jobs.labels.iou")}>
<BranchesOutlined style={{ color: "orangered" }} />
</Tooltip>
)}
</span>
</Space>
</Link>
)
},
{
title: t("jobs.fields.owner"),
dataIndex: "owner",
key: "owner",
ellipsis: true,
sorter: (a, b) => alphaSort(OwnerNameDisplayFunction(a), OwnerNameDisplayFunction(b)),
sortOrder: state.sortedInfo.columnKey === "owner" && state.sortedInfo.order,
render: (text, record) => {
return record.ownerid ? (
<Link to={"/manage/owners/" + record.ownerid} onClick={(e) => e.stopPropagation()}>
<span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>
<OwnerNameDisplay ownerObject={record} />
</span>
</Link>
) : (
<span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>
<OwnerNameDisplay ownerObject={record} />
</span>
);
}
},
{
title: t("jobs.fields.vehicle"),
dataIndex: "vehicle",
key: "vehicle",
ellipsis: true,
sorter: (a, b) =>
alphaSort(
`${a.v_model_yr || ""} ${a.v_make_desc || ""} ${a.v_model_desc || ""}`,
`${b.v_model_yr || ""} ${b.v_make_desc || ""} ${b.v_model_desc || ""}`
),
sortOrder: state.sortedInfo.columnKey === "vehicle" && state.sortedInfo.order,
render: (text, record) => {
return record.vehicleid ? (
<Link to={"/manage/vehicles/" + record.vehicleid} onClick={(e) => e.stopPropagation()}>
<span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>
{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""}`}
</span>
</Link>
) : (
<span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>{`${
record.v_model_yr || ""
} ${record.v_make_desc || ""} ${record.v_model_desc || ""}`}</span>
);
}
},
{
title: t("appointments.fields.alt_transport"),
dataIndex: "alt_transport",
key: "alt_transport",
ellipsis: true,
sorter: (a, b) => alphaSort(a.alt_transport, b.alt_transport),
sortOrder: state.sortedInfo.columnKey === "alt_transport" && state.sortedInfo.order,
filters:
(scheduledDeliveryToday &&
scheduledDeliveryToday
.map((j) => j.alt_transport)
.filter(onlyUnique)
.map((s) => {
return {
text: s || t("dashboard.errors.atp"),
value: [s]
};
})
.sort((a, b) => alphaSort(a.text, b.text))) ||
[],
onFilter: (value, record) => value.includes(record.alt_transport),
render: (text, record) => (
<span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>{record.alt_transport}</span>
)
},
{
title: t("jobs.fields.status"),
dataIndex: "status",
key: "status",
ellipsis: true,
sorter: (a, b) => alphaSort(a.status, b.status),
sortOrder: state.sortedInfo.columnKey === "status" && state.sortedInfo.order,
filters:
(scheduledDeliveryToday &&
scheduledDeliveryToday
.map((j) => j.status)
.filter(onlyUnique)
.map((s) => {
return {
text: s || t("dashboard.errors.status"),
value: [s]
};
})
.sort((a, b) => alphaSort(a.text, b.text))) ||
[],
onFilter: (value, record) => value.includes(record.status),
render: (text, record) => <span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>{record.status}</span>
},
{
title: t("jobs.fields.lab"),
dataIndex: "joblines_body",
key: "joblines_body",
sorter: (a, b) => a.joblines_body - b.joblines_body,
sortOrder: state.sortedInfo.columnKey === "joblines_body" && state.sortedInfo.order,
align: "right",
render: (text, record) => (
<span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>{record.joblines_body.toFixed(1)}</span>
)
},
{
title: t("jobs.fields.lar"),
dataIndex: "joblines_ref",
key: "joblines_ref",
sorter: (a, b) => a.joblines_ref - b.joblines_ref,
sortOrder: state.sortedInfo.columnKey === "joblines_ref" && state.sortedInfo.order,
align: "right",
render: (text, record) => (
<span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>{record.joblines_ref.toFixed(1)}</span>
)
}
];
const columns = [
{
title: t("jobs.fields.scheduled_delivery"),
dataIndex: "scheduled_delivery",
key: "scheduled_delivery",
ellipsis: true,
sorter: (a, b) => dateSort(a.scheduled_delivery, b.scheduled_delivery),
sortOrder: state.sortedInfo.columnKey === "scheduled_delivery" && state.sortedInfo.order,
render: (text, record) => <TimeFormatter>{record.scheduled_delivery}</TimeFormatter>
},
{
title: t("jobs.fields.ro_number"),
dataIndex: "ro_number",
key: "ro_number",
sorter: (a, b) => alphaSort(a.ro_number, b.ro_number),
sortOrder: state.sortedInfo.columnKey === "ro_number" && state.sortedInfo.order,
render: (text, record) => (
<Link to={"/manage/jobs/" + record.jobid} onClick={(e) => e.stopPropagation()}>
<Space>
{record.ro_number || t("general.labels.na")}
{record.production_vars && record.production_vars.alert ? (
<ExclamationCircleFilled className="production-alert" />
) : null}
{record.suspended && <PauseCircleOutlined style={{ color: "orangered" }} />}
{record.iouparent && (
<Tooltip title={t("jobs.labels.iou")}>
<BranchesOutlined style={{ color: "orangered" }} />
</Tooltip>
)}
</Space>
</Link>
)
},
{
title: t("jobs.fields.owner"),
dataIndex: "owner",
key: "owner",
ellipsis: true,
sorter: (a, b) => alphaSort(OwnerNameDisplayFunction(a), OwnerNameDisplayFunction(b)),
sortOrder: state.sortedInfo.columnKey === "owner" && state.sortedInfo.order,
render: (text, record) => {
return record.ownerid ? (
<Link to={"/manage/owners/" + record.ownerid} onClick={(e) => e.stopPropagation()}>
<OwnerNameDisplay ownerObject={record} />
</Link>
) : (
<span>
<OwnerNameDisplay ownerObject={record} />
</span>
);
}
},
{
title: t("dashboard.labels.phone"),
dataIndex: "ownr_ph",
key: "ownr_ph",
ellipsis: true,
responsive: ["md"],
render: (text, record) => (
<Space size="small" wrap>
<ChatOpenButton phone={record.ownr_ph1} jobid={record.jobid} />
<ChatOpenButton phone={record.ownr_ph2} jobid={record.jobid} />
</Space>
)
},
{
title: t("jobs.fields.ownr_ea"),
dataIndex: "ownr_ea",
key: "ownr_ea",
ellipsis: true,
responsive: ["md"],
render: (text, record) => <a href={`mailto:${record.ownr_ea}`}>{record.ownr_ea}</a>
},
{
title: t("jobs.fields.vehicle"),
dataIndex: "vehicle",
key: "vehicle",
ellipsis: true,
sorter: (a, b) =>
alphaSort(
`${a.v_model_yr || ""} ${a.v_make_desc || ""} ${a.v_model_desc || ""}`,
`${b.v_model_yr || ""} ${b.v_make_desc || ""} ${b.v_model_desc || ""}`
),
sortOrder: state.sortedInfo.columnKey === "vehicle" && state.sortedInfo.order,
render: (text, record) => {
return record.vehicleid ? (
<Link to={"/manage/vehicles/" + record.vehicleid} onClick={(e) => e.stopPropagation()}>
{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""}`}
</Link>
) : (
<span>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""}`}</span>
);
}
},
{
title: t("jobs.fields.ins_co_nm"),
dataIndex: "ins_co_nm",
key: "ins_co_nm",
ellipsis: true,
responsive: ["md"],
sorter: (a, b) => alphaSort(a.ins_co_nm, b.ins_co_nm),
sortOrder: state.sortedInfo.columnKey === "ins_co_nm" && state.sortedInfo.order,
filters:
(scheduledDeliveryToday &&
scheduledDeliveryToday
.map((j) => j.ins_co_nm)
.filter(onlyUnique)
.map((s) => {
return {
text: s || t("dashboard.errors.insco"),
value: [s]
};
})
.sort((a, b) => alphaSort(a.text, b.text))) ||
[],
onFilter: (value, record) => value.includes(record.ins_co_nm)
},
{
title: t("appointments.fields.alt_transport"),
dataIndex: "alt_transport",
key: "alt_transport",
ellipsis: true,
sorter: (a, b) => alphaSort(a.alt_transport, b.alt_transport),
sortOrder: state.sortedInfo.columnKey === "alt_transport" && state.sortedInfo.order,
filters:
(scheduledDeliveryToday &&
scheduledDeliveryToday
.map((j) => j.alt_transport)
.filter(onlyUnique)
.map((s) => {
return {
text: s || t("dashboard.errors.atp"),
value: [s]
};
})
.sort((a, b) => alphaSort(a.text, b.text))) ||
[],
onFilter: (value, record) => value.includes(record.alt_transport)
}
];
const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
};
return (
<Card
title={t("dashboard.titles.scheduleddeliverydate", {
date: dayjs().startOf("day").format("MM/DD/YYYY")
})}
extra={
<Space>
<Typography.Text>{t("general.labels.tvmode")}</Typography.Text>
<Switch
onClick={() => setIsTvModeScheduledDelivery(!isTvModeScheduledDelivery)}
defaultChecked={isTvModeScheduledDelivery}
/>
</Space>
}
{...cardProps}
>
<div style={{ height: "100%" }}>
<Table
onChange={handleTableChange}
pagination={false}
columns={isTvModeScheduledDelivery ? tvColumns : columns}
scroll={{ x: true, y: "calc(100% - 2em)" }}
rowKey="id"
style={{ height: "85%" }}
dataSource={scheduledDeliveryToday}
size={isTvModeScheduledDelivery ? "small" : "middle"}
/>
</div>
</Card>
);
}
export const DashboardScheduledDeliveryTodayGql = `
scheduled_delivery_today: jobs(where: {
date_invoiced: {_is_null: true},
ro_number: {_is_null: false},
voided: {_eq: false},
scheduled_delivery: {_gte: "${dayjs().startOf("day").toISOString()}",
_lte: "${dayjs().endOf("day").toISOString()}"}}) {
alt_transport
clm_no
jobid: id
joblines(where: {removed: {_eq: false}}) {
mod_lb_hrs
mod_lbr_ty
}
ins_co_nm
iouparent
ownerid
ownr_co_nm
ownr_ea
ownr_fn
ownr_ln
ownr_ph1
ownr_ph2
production_vars
ro_number
scheduled_delivery
status
suspended
v_make_desc
v_model_desc
v_model_yr
v_vin
vehicleid
}
`;

View File

@@ -1,11 +1,11 @@
import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined } from "@ant-design/icons"; import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined } from "@ant-design/icons";
import { Card, Space, Switch, Table, Tooltip, Typography } from "antd"; import { Card, Space, Switch, Table, Tooltip, Typography } from "antd";
import dayjs from "../../../utils/day"; import { useState } from "react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { TimeFormatter } from "../../../utils/DateFormatter"; import { TimeFormatter } from "../../../utils/DateFormatter";
import { onlyUnique } from "../../../utils/arrayHelper"; import { onlyUnique } from "../../../utils/arrayHelper";
import dayjs from "../../../utils/day";
import { alphaSort, dateSort } from "../../../utils/sorters"; import { alphaSort, dateSort } from "../../../utils/sorters";
import useLocalStorage from "../../../utils/useLocalStorage"; import useLocalStorage from "../../../utils/useLocalStorage";
import ChatOpenButton from "../../chat-open-button/chat-open-button.component"; import ChatOpenButton from "../../chat-open-button/chat-open-button.component";
@@ -169,7 +169,7 @@ export default function DashboardScheduledInToday({ data, ...cardProps }) {
.filter(onlyUnique) .filter(onlyUnique)
.map((s) => { .map((s) => {
return { return {
text: s || "No Alt. Transport", text: s || t("dashboard.errors.atp"),
value: [s] value: [s]
}; };
}) })
@@ -313,7 +313,7 @@ export default function DashboardScheduledInToday({ data, ...cardProps }) {
.filter(onlyUnique) .filter(onlyUnique)
.map((s) => { .map((s) => {
return { return {
text: s || "No Ins. Co.*", text: s || t("dashboard.errors.insco"),
value: [s] value: [s]
}; };
}) })
@@ -335,7 +335,7 @@ export default function DashboardScheduledInToday({ data, ...cardProps }) {
.filter(onlyUnique) .filter(onlyUnique)
.map((s) => { .map((s) => {
return { return {
text: s || "No Alt. Transport", text: s || t("dashboard.errors.atp"),
value: [s] value: [s]
}; };
}) })

View File

@@ -1,11 +1,11 @@
import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined } from "@ant-design/icons"; import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined } from "@ant-design/icons";
import { Card, Space, Switch, Table, Tooltip, Typography } from "antd"; import { Card, Space, Switch, Table, Tooltip, Typography } from "antd";
import dayjs from "../../../utils/day"; import { useState } from "react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { TimeFormatter } from "../../../utils/DateFormatter"; import { TimeFormatter } from "../../../utils/DateFormatter";
import { onlyUnique } from "../../../utils/arrayHelper"; import { onlyUnique } from "../../../utils/arrayHelper";
import dayjs from "../../../utils/day";
import { alphaSort, dateSort } from "../../../utils/sorters"; import { alphaSort, dateSort } from "../../../utils/sorters";
import useLocalStorage from "../../../utils/useLocalStorage"; import useLocalStorage from "../../../utils/useLocalStorage";
import ChatOpenButton from "../../chat-open-button/chat-open-button.component"; import ChatOpenButton from "../../chat-open-button/chat-open-button.component";
@@ -138,7 +138,7 @@ export default function DashboardScheduledOutToday({ data, ...cardProps }) {
.filter(onlyUnique) .filter(onlyUnique)
.map((s) => { .map((s) => {
return { return {
text: s || "No Alt. Transport*", text: s || t("dashboard.errors.atp"),
value: [s] value: [s]
}; };
}) })
@@ -154,7 +154,7 @@ export default function DashboardScheduledOutToday({ data, ...cardProps }) {
dataIndex: "status", dataIndex: "status",
key: "status", key: "status",
ellipsis: true, ellipsis: true,
sorter: (a, b) => alphaSort(a.alt_transport, b.alt_transport), sorter: (a, b) => alphaSort(a.status, b.status),
sortOrder: state.sortedInfo.columnKey === "status" && state.sortedInfo.order, sortOrder: state.sortedInfo.columnKey === "status" && state.sortedInfo.order,
filters: filters:
(scheduledOutToday && (scheduledOutToday &&
@@ -163,7 +163,7 @@ export default function DashboardScheduledOutToday({ data, ...cardProps }) {
.filter(onlyUnique) .filter(onlyUnique)
.map((s) => { .map((s) => {
return { return {
text: s || "No Status*", text: s || t("dashboard.errors.status"),
value: [s] value: [s]
}; };
}) })
@@ -306,7 +306,7 @@ export default function DashboardScheduledOutToday({ data, ...cardProps }) {
.filter(onlyUnique) .filter(onlyUnique)
.map((s) => { .map((s) => {
return { return {
text: s || "No Ins. Co.*", text: s || t("dashboard.errors.insco"),
value: [s] value: [s]
}; };
}) })
@@ -328,7 +328,7 @@ export default function DashboardScheduledOutToday({ data, ...cardProps }) {
.filter(onlyUnique) .filter(onlyUnique)
.map((s) => { .map((s) => {
return { return {
text: s || "No Alt. Transport*", text: s || t("dashboard.errors.atp"),
value: [s] value: [s]
}; };
}) })

View File

@@ -1,30 +1,33 @@
import i18next from "i18next"; import i18next from "i18next";
import DashboardTotalProductionDollars from "../dashboard-components/total-production-dollars/total-production-dollars.component.jsx"; import JobLifecycleDashboardComponent, {
import { JobLifecycleDashboardGQL
DashboardTotalProductionHours, } from "../dashboard-components/job-lifecycle/job-lifecycle-dashboard.component.jsx";
DashboardTotalProductionHoursGql
} from "../dashboard-components/total-production-hours/total-production-hours.component.jsx";
import DashboardProjectedMonthlySales, {
DashboardProjectedMonthlySalesGql
} from "../dashboard-components/pojected-monthly-sales/projected-monthly-sales.component.jsx";
import DashboardMonthlyRevenueGraph, {
DashboardMonthlyRevenueGraphGql
} from "../dashboard-components/monthly-revenue-graph/monthly-revenue-graph.component.jsx";
import DashboardMonthlyJobCosting from "../dashboard-components/monthly-job-costing/monthly-job-costing.component.jsx";
import DashboardMonthlyPartsSales from "../dashboard-components/monthly-parts-sales/monthly-parts-sales.component.jsx";
import DashboardMonthlyLaborSales from "../dashboard-components/monthly-labor-sales/monthly-labor-sales.component.jsx";
import DashboardMonthlyEmployeeEfficiency, { import DashboardMonthlyEmployeeEfficiency, {
DashboardMonthlyEmployeeEfficiencyGql DashboardMonthlyEmployeeEfficiencyGql
} from "../dashboard-components/monthly-employee-efficiency/monthly-employee-efficiency.component.jsx"; } from "../dashboard-components/monthly-employee-efficiency/monthly-employee-efficiency.component.jsx";
import DashboardMonthlyJobCosting from "../dashboard-components/monthly-job-costing/monthly-job-costing.component.jsx";
import DashboardMonthlyLaborSales from "../dashboard-components/monthly-labor-sales/monthly-labor-sales.component.jsx";
import DashboardMonthlyPartsSales from "../dashboard-components/monthly-parts-sales/monthly-parts-sales.component.jsx";
import DashboardMonthlyRevenueGraph, {
DashboardMonthlyRevenueGraphGql
} from "../dashboard-components/monthly-revenue-graph/monthly-revenue-graph.component.jsx";
import DashboardProjectedMonthlySales, {
DashboardProjectedMonthlySalesGql
} from "../dashboard-components/pojected-monthly-sales/projected-monthly-sales.component.jsx";
import DashboardScheduledDeliveryToday, {
DashboardScheduledDeliveryTodayGql
} from "../dashboard-components/scheduled-delivery-today/scheduled-delivery-today.component.jsx";
import DashboardScheduledInToday, { import DashboardScheduledInToday, {
DashboardScheduledInTodayGql DashboardScheduledInTodayGql
} from "../dashboard-components/scheduled-in-today/scheduled-in-today.component.jsx"; } from "../dashboard-components/scheduled-in-today/scheduled-in-today.component.jsx";
import DashboardScheduledOutToday, { import DashboardScheduledOutToday, {
DashboardScheduledOutTodayGql DashboardScheduledOutTodayGql
} from "../dashboard-components/scheduled-out-today/scheduled-out-today.component.jsx"; } from "../dashboard-components/scheduled-out-today/scheduled-out-today.component.jsx";
import JobLifecycleDashboardComponent, { import DashboardTotalProductionDollars from "../dashboard-components/total-production-dollars/total-production-dollars.component.jsx";
JobLifecycleDashboardGQL import {
} from "../dashboard-components/job-lifecycle/job-lifecycle-dashboard.component.jsx"; DashboardTotalProductionHours,
DashboardTotalProductionHoursGql
} from "../dashboard-components/total-production-hours/total-production-hours.component.jsx";
const componentList = { const componentList = {
ProductionDollars: { ProductionDollars: {
@@ -118,6 +121,15 @@ const componentList = {
w: 10, w: 10,
h: 3 h: 3
}, },
ScheduleDeliveryToday: {
label: i18next.t("dashboard.titles.scheduleddeliverytoday"),
component: DashboardScheduledDeliveryToday,
gqlFragment: DashboardScheduledDeliveryTodayGql,
minW: 6,
minH: 2,
w: 10,
h: 3
},
JobLifecycle: { JobLifecycle: {
label: i18next.t("dashboard.titles.joblifecycle"), label: i18next.t("dashboard.titles.joblifecycle"),
component: JobLifecycleDashboardComponent, component: JobLifecycleDashboardComponent,

View File

@@ -14,7 +14,6 @@ import {
Typography Typography
} from "antd"; } from "antd";
import Dinero from "dinero.js"; import Dinero from "dinero.js";
import React 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";
@@ -24,14 +23,14 @@ import i18n from "../../translations/i18n";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import DmsCdkMakes from "../dms-cdk-makes/dms-cdk-makes.component"; import DmsCdkMakes from "../dms-cdk-makes/dms-cdk-makes.component";
import DmsCdkMakesRefetch from "../dms-cdk-makes/dms-cdk-makes.refetch.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 CurrencyInput from "../form-items-formatted/currency-form-item.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export default connect(mapStateToProps, mapDispatchToProps)(DmsPostForm); export default connect(mapStateToProps, mapDispatchToProps)(DmsPostForm);
@@ -93,7 +92,9 @@ export function DmsPostForm({ bodyshop, socket, job, logsRef }) {
}) })
: "" : ""
}`.slice(0, 239), }`.slice(0, 239),
inservicedate: dayjs("2019-01-01") 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> <LayoutFormRow grow>

View File

@@ -1,6 +1,6 @@
import { DatePicker, Space, TimePicker } from "antd"; import { DatePicker, Space, TimePicker } from "antd";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import React, { useCallback, useState } from "react"; import { useCallback, 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";
@@ -94,7 +94,24 @@ const DateTimePicker = ({
showTime={false} showTime={false}
format="MM/DD/YYYY" format="MM/DD/YYYY"
value={value ? dayjs(value) : null} value={value ? dayjs(value) : null}
onChange={handleChange} onChange={(dateValue) => {
if (dateValue) {
// When date changes, preserve the existing time if it exists
if (value && dayjs(value).isValid()) {
const existingTime = dayjs(value);
const newDateTime = dayjs(dateValue)
.hour(existingTime.hour())
.minute(existingTime.minute())
.second(existingTime.second());
handleChange(newDateTime);
} else {
// If no existing time, just set the date without time
handleChange(dateValue);
}
} else {
handleChange(dateValue);
}
}}
placeholder={t("general.labels.date")} placeholder={t("general.labels.date")}
onBlur={handleBlur} onBlur={handleBlur}
disabledDate={handleDisabledDate} disabledDate={handleDisabledDate}
@@ -105,13 +122,25 @@ const DateTimePicker = ({
<TimePicker <TimePicker
format="hh:mm a" format="hh:mm a"
minuteStep={15} minuteStep={15}
value={value && dayjs(value).hour() === 0 && dayjs(value).minute() === 0 ? null : dayjs(value)}
defaultOpenValue={dayjs(value) defaultOpenValue={dayjs(value)
.hour(dayjs().hour()) .hour(dayjs().hour())
.minute(Math.floor(dayjs().minute() / 15) * 15) .minute(Math.floor(dayjs().minute() / 15) * 15)
.second(0)} .second(0)}
onChange={(value) => { onChange={(timeValue) => {
handleChange(value); if (timeValue) {
onBlur(); // When time changes, combine it with the existing date
const existingDate = dayjs(value);
const newDateTime = existingDate
.hour(timeValue.hour())
.minute(timeValue.minute())
.second(0);
handleChange(newDateTime);
} else {
// If time is cleared, just update with null time but keep date
handleChange(timeValue);
}
if (onBlur) onBlur();
}} }}
placeholder={t("general.labels.time")} placeholder={t("general.labels.time")}
{...restProps} {...restProps}

View File

@@ -0,0 +1,190 @@
import { Link } from "react-router-dom";
import { FaCreditCard, FaFileInvoiceDollar } from "react-icons/fa";
import { GiPayMoney, GiPlayerTime } from "react-icons/gi";
import { BankFilled, ExportOutlined, FieldTimeOutlined } from "@ant-design/icons";
import LockWrapper from "../../components/lock-wrapper/lock-wrapper.component.jsx";
import { HasFeatureAccess } from "../../components/feature-wrapper/feature-wrapper.component";
// --- Menu Item Builders ---
const buildAccountingChildren = ({
t,
bodyshop,
currentUser,
setBillEnterContext,
setPaymentContext,
setCardPaymentContext,
setTimeTicketContext,
ImEXPay,
DmsAp,
Simple_Inventory
}) => [
{
key: "bills",
id: "header-accounting-bills",
icon: <FaFileInvoiceDollar />,
label: (
<Link to="/manage/bills">
<LockWrapper featureName="bills" bodyshop={bodyshop}>
{t("menus.header.bills")}
</LockWrapper>
</Link>
)
},
{
key: "enterbills",
id: "header-accounting-enterbills",
icon: <GiPayMoney />,
label: (
<LockWrapper featureName="bills" bodyshop={bodyshop}>
{t("menus.header.enterbills")}
</LockWrapper>
),
onClick: () =>
HasFeatureAccess({ featureName: "bills", bodyshop }) && setBillEnterContext({ actions: {}, context: {} })
},
...(Simple_Inventory.treatment === "on"
? [
{ type: "divider" },
{
key: "inventory",
id: "header-accounting-inventory",
icon: <FaFileInvoiceDollar />,
label: <Link to="/manage/inventory">{t("menus.header.inventory")}</Link>
}
]
: []),
{ type: "divider" },
{
key: "allpayments",
id: "header-accounting-allpayments",
icon: <BankFilled />,
label: <Link to="/manage/payments">{t("menus.header.allpayments")}</Link>
},
{
key: "enterpayments",
id: "header-accounting-enterpayments",
icon: <FaCreditCard />,
label: t("menus.header.enterpayment"),
onClick: () => setPaymentContext({ actions: {}, context: null })
},
...(ImEXPay.treatment === "on"
? [
{
key: "entercardpayments",
id: "header-accounting-entercardpayments",
icon: <FaCreditCard />,
label: t("menus.header.entercardpayment"),
onClick: () => setCardPaymentContext({ actions: {}, context: {} })
}
]
: []),
{ type: "divider" },
{
key: "timetickets",
id: "header-accounting-timetickets",
icon: <FieldTimeOutlined />,
label: (
<Link to="/manage/timetickets">
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
{t("menus.header.timetickets")}
</LockWrapper>
</Link>
)
},
...(bodyshop?.md_tasks_presets?.use_approvals
? [
{
key: "ttapprovals",
id: "header-accounting-ttapprovals",
icon: <FieldTimeOutlined />,
label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link>
}
]
: []),
{
key: "entertimetickets",
id: "header-accounting-entertimetickets",
icon: <GiPlayerTime />,
label: (
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
{t("menus.header.entertimeticket")}
</LockWrapper>
),
onClick: () =>
HasFeatureAccess({ featureName: "timetickets", bodyshop }) &&
setTimeTicketContext({
actions: {},
context: {
created_by: currentUser.displayName ? `${currentUser.email} | ${currentUser.displayName}` : currentUser.email
}
})
},
{ type: "divider" },
{
key: "accountingexport",
id: "header-accounting-export",
icon: <ExportOutlined />,
label: (
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export")}
</LockWrapper>
),
children: [
{
key: "receivables",
id: "header-accounting-receivables",
label: (
<Link to="/manage/accounting/receivables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-receivables")}
</LockWrapper>
</Link>
)
},
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) || DmsAp.treatment === "on"
? [
{
key: "payables",
id: "header-accounting-payables",
label: (
<Link to="/manage/accounting/payables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payables")}
</LockWrapper>
</Link>
)
}
]
: []),
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber))
? [
{
key: "payments",
id: "header-accounting-payments",
label: (
<Link to="/manage/accounting/payments">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payments")}
</LockWrapper>
</Link>
)
}
]
: []),
{ type: "divider" },
{
key: "exportlogs",
id: "header-accounting-exportlogs",
label: (
<Link to="/manage/accounting/exportlogs">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export-logs")}
</LockWrapper>
</Link>
)
}
]
}
];
export default buildAccountingChildren;

View File

@@ -0,0 +1,390 @@
import { Link } from "react-router-dom";
import {
BarChartOutlined,
CarFilled,
CheckCircleOutlined,
ClockCircleFilled,
DashboardFilled,
DollarCircleFilled,
FileAddFilled,
FileAddOutlined,
FileFilled,
HomeFilled,
ImportOutlined,
LineChartOutlined,
OneToOneOutlined,
PaperClipOutlined,
PhoneOutlined,
PlusCircleOutlined,
QuestionCircleFilled,
ScheduleOutlined,
SettingOutlined,
TeamOutlined,
ToolFilled,
UnorderedListOutlined,
UsergroupAddOutlined,
UserOutlined
} from "@ant-design/icons";
import { FaCalendarAlt, FaCarCrash, FaTasks } from "react-icons/fa";
import { BsKanban } from "react-icons/bs";
import { FiLogOut } from "react-icons/fi";
import { GiPlayerTime, GiSettingsKnobs } from "react-icons/gi";
import { RiSurveyLine } from "react-icons/ri";
import { IoBusinessOutline } from "react-icons/io5";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import LockWrapper from "../../components/lock-wrapper/lock-wrapper.component.jsx";
const buildLeftMenuItems = ({
t,
bodyshop,
recentItems,
setTaskUpsertContext,
setReportCenterContext,
signOutStart,
accountingChildren
}) => {
return [
{
key: "home",
id: "header-home",
icon: <HomeFilled />,
label: <Link to="/manage/">{t("menus.header.home")}</Link>
},
{
key: "schedule",
id: "header-schedule",
icon: <FaCalendarAlt />,
label: <Link to="/manage/schedule">{t("menus.header.schedule")}</Link>
},
{
key: "jobssubmenu",
id: "header-jobs",
icon: <FaCarCrash />,
label: t("menus.header.jobs"),
children: [
{
key: "activejobs",
id: "header-active-jobs",
icon: <FileFilled />,
label: <Link to="/manage/jobs">{t("menus.header.activejobs")}</Link>
},
{
key: "readyjobs",
id: "header-ready-jobs",
icon: <CheckCircleOutlined />,
label: <Link to="/manage/jobs/ready">{t("menus.header.readyjobs")}</Link>
},
{
key: "parts-queue",
id: "header-parts-queue",
icon: <ToolFilled />,
label: <Link to="/manage/partsqueue">{t("menus.header.parts-queue")}</Link>
},
{
key: "availablejobs",
id: "header-jobs-available",
icon: <ImportOutlined />,
label: <Link to="/manage/available">{t("menus.header.availablejobs")}</Link>
},
{
key: "newjob",
id: "header-new-job",
icon: <FileAddOutlined />,
label: <Link to="/manage/jobs/new">{t("menus.header.newjob")}</Link>
},
{ type: "divider" },
{
key: "alljobs",
id: "header-all-jobs",
icon: <UnorderedListOutlined />,
label: <Link to="/manage/jobs/all">{t("menus.header.alljobs")}</Link>
},
{ type: "divider" },
{
key: "productionlist",
id: "header-production-list",
icon: <ScheduleOutlined />,
label: <Link to="/manage/production/list">{t("menus.header.productionlist")}</Link>
},
{
key: "productionboard",
id: "header-production-board",
icon: <BsKanban />,
label: (
<Link to="/manage/production/board">
<LockWrapper featureName="visualboard" bodyshop={bodyshop}>
{t("menus.header.productionboard")}
</LockWrapper>
</Link>
)
},
{ type: "divider" },
{
key: "scoreboard",
id: "header-scoreboard",
icon: <LineChartOutlined />,
label: (
<Link to="/manage/scoreboard">
<LockWrapper featureName="scoreboard" bodyshop={bodyshop}>
{t("menus.header.scoreboard")}
</LockWrapper>
</Link>
)
}
]
},
{
key: "customers",
id: "header-customers",
icon: <UserOutlined />,
label: t("menus.header.customers"),
children: [
{
key: "owners",
id: "header-owners",
icon: <TeamOutlined />,
label: <Link to="/manage/owners">{t("menus.header.owners")}</Link>
},
{
key: "vehicles",
id: "header-vehicles",
icon: <CarFilled />,
label: <Link to="/manage/vehicles">{t("menus.header.vehicles")}</Link>
}
]
},
{
key: "ccs",
id: "header-css",
icon: <CarFilled />,
label: (
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars")}
</LockWrapper>
),
children: [
{
key: "courtesycarsall",
id: "header-courtesycars-all",
icon: <CarFilled />,
label: (
<Link to="/manage/courtesycars">
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars-all")}
</LockWrapper>
</Link>
)
},
{
key: "contracts",
id: "header-contracts",
icon: <FileFilled />,
label: (
<Link to="/manage/courtesycars/contracts">
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars-contracts")}
</LockWrapper>
</Link>
)
},
{
key: "newcontract",
id: "header-newcontract",
icon: <FileAddFilled />,
label: (
<Link to="/manage/courtesycars/contracts/new">
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars-newcontract")}
</LockWrapper>
</Link>
)
}
]
},
...(accountingChildren.length > 0
? [
{
key: "accounting",
id: "header-accounting",
icon: <DollarCircleFilled />,
label: t("menus.header.accounting"),
children: accountingChildren
}
]
: []),
{
key: "phonebook",
id: "header-phonebook",
icon: <PhoneOutlined />,
label: <Link to="/manage/phonebook">{t("menus.header.phonebook")}</Link>
},
{
key: "temporarydocs",
id: "header-temporarydocs",
icon: <PaperClipOutlined />,
label: (
<Link to="/manage/temporarydocs">
<LockWrapper featureName="media" bodyshop={bodyshop}>
{t("menus.header.temporarydocs")}
</LockWrapper>
</Link>
)
},
{
key: "tasks",
id: "tasks",
icon: <FaTasks />,
label: t("menus.header.tasks"),
children: [
{
key: "createTask",
id: "header-create-task",
icon: <PlusCircleOutlined />,
label: t("menus.header.create_task"),
onClick: () => setTaskUpsertContext({ actions: {}, context: {} })
},
{
key: "mytasks",
id: "header-my-tasks",
icon: <FaTasks />,
label: <Link to="/manage/tasks/mytasks">{t("menus.header.my_tasks")}</Link>
},
{
key: "all_tasks",
id: "header-all-tasks",
icon: <FaTasks />,
label: <Link to="/manage/tasks/alltasks">{t("menus.header.all_tasks")}</Link>
}
]
},
{
key: "shopsubmenu",
id: "header-shopsubmenu",
icon: <SettingOutlined />,
label: t("menus.header.shop"),
children: [
{
key: "shop",
id: "header-shop",
icon: <GiSettingsKnobs />,
label: <Link to="/manage/shop?tab=info">{t("menus.header.shop_config")}</Link>
},
{
key: "dashboard",
id: "header-dashboard",
icon: <DashboardFilled />,
label: (
<Link to="/manage/dashboard">
<LockWrapper featureName="bills">{t("menus.header.dashboard")}</LockWrapper>
</Link>
)
},
{
key: "reportcenter",
id: "header-reportcenter",
icon: <BarChartOutlined />,
label: t("menus.header.reportcenter"),
onClick: () => setReportCenterContext({ actions: {}, context: {} })
},
{
key: "shop-vendors",
id: "header-shop-vendors",
icon: <IoBusinessOutline />,
label: <Link to="/manage/shop/vendors">{t("menus.header.shop_vendors")}</Link>
},
{
key: "shop-csi",
id: "header-shop-csi",
icon: <RiSurveyLine />,
label: (
<Link to="/manage/shop/csi">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.shop_csi")}
</LockWrapper>
</Link>
)
}
]
},
{
key: "recent",
id: "header-recent",
icon: <ClockCircleFilled />,
label: t("menus.header.recent"),
children: recentItems.map((i, idx) => ({
key: idx,
id: `header-recent-${idx}`,
label: <Link to={i.url}>{i.label}</Link>
}))
},
{
key: "user",
id: "header-user",
icon: <UserOutlined />,
label: t("menus.currentuser.profile"),
children: [
{
key: "signout",
id: "header-signout",
icon: <FiLogOut />,
danger: true,
label: t("user.actions.signout"),
onClick: () => signOutStart()
},
{
key: "help",
id: "header-help",
icon: <QuestionCircleFilled />,
label: t("menus.header.help"),
onClick: () => window.open("https://help.imex.online/", "_blank")
},
{
key: "remoteassist",
id: "header-remote-assist",
icon: <OneToOneOutlined />,
label: t("menus.header.remoteassist"),
children: [
...(InstanceRenderManager({ imex: true, rome: false })
? [
{
key: "rescue",
id: "header-rescue",
icon: <PlusCircleOutlined />,
label: t("menus.header.rescueme"),
onClick: () => window.open("https://imexrescue.com/", "_blank")
}
]
: []),
{
key: "rescue-zoho",
id: "header-rescue-zoho",
icon: <UsergroupAddOutlined />,
label: t("menus.header.rescuemezoho"),
onClick: () => window.open("https://join.zoho.com/", "_blank")
}
]
},
{
key: "shiftclock",
id: "header-shiftclock",
icon: <GiPlayerTime />,
label: (
<Link to="/manage/shiftclock">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.shiftclock")}
</LockWrapper>
</Link>
)
},
{
key: "profile",
id: "header-profile",
icon: <UserOutlined />,
label: <Link to="/manage/profile">{t("menus.currentuser.profile")}</Link>
}
]
}
];
};
export default buildLeftMenuItems;

View File

@@ -1,59 +1,29 @@
import { // noinspection RegExpAnonymousGroup
BankFilled,
BarChartOutlined, import { BellFilled } from "@ant-design/icons";
BellFilled,
CarFilled,
CheckCircleOutlined,
ClockCircleFilled,
DashboardFilled,
DollarCircleFilled,
ExportOutlined,
FieldTimeOutlined,
FileAddFilled,
FileAddOutlined,
FileFilled,
HomeFilled,
ImportOutlined,
LineChartOutlined,
PaperClipOutlined,
PhoneOutlined,
PlusCircleOutlined,
QuestionCircleFilled,
ScheduleOutlined,
SettingOutlined,
TeamOutlined,
ToolFilled,
UnorderedListOutlined,
UserOutlined
} from "@ant-design/icons";
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Badge, Layout, Menu, Spin } from "antd"; import { Badge, Layout, Menu, Spin, Tooltip } from "antd";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BsKanban } from "react-icons/bs"; import { FaTasks } from "react-icons/fa";
import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa";
import { FiLogOut } from "react-icons/fi";
import { GiPayMoney, GiPlayerTime, GiSettingsKnobs } from "react-icons/gi";
import { IoBusinessOutline } from "react-icons/io5";
import { RiSurveyLine } from "react-icons/ri";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { TASKS_CENTER_POLL_INTERVAL, useSocket } from "../../contexts/SocketIO/useSocket.js";
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js"; import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
import { QUERY_MY_TASKS_COUNT } from "../../graphql/tasks.queries.js";
import { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors"; import { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions"; import { setModalContext } from "../../redux/modals/modals.actions";
import { signOutStart } from "../../redux/user/user.actions"; import { signOutStart } from "../../redux/user/user.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import day from "../../utils/day.js"; import day from "../../utils/day.js";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useIsEmployee } from "../../utils/useIsEmployee.js"; import { useIsEmployee } from "../../utils/useIsEmployee.js";
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
import TaskCenterContainer from "../task-center/task-center.container.jsx";
import buildAccountingChildren from "./buildAccountingChildren.jsx";
import buildLeftMenuItems from "./buildLeftMenuItems.jsx";
// Redux mappings // --- Redux mappings ---
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser, currentUser: selectCurrentUser,
recentItems: selectRecentItems, recentItems: selectRecentItems,
@@ -71,36 +41,8 @@ const mapDispatchToProps = (dispatch) => ({
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" })) setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" }))
}); });
function Header({ // --- Utility Hooks ---
handleMenuClick, function useUnreadNotifications(userAssociationId, isConnected, scenarioNotificationsOn) {
currentUser,
bodyshop,
selectedHeader,
signOutStart,
setBillEnterContext,
setTimeTicketContext,
setPaymentContext,
setReportCenterContext,
recentItems,
setCardPaymentContext,
setTaskUpsertContext
}) {
const {
treatments: { ImEXPay, DmsAp, Simple_Inventory }
} = useSplitTreatments({
attributes: {},
names: ["ImEXPay", "DmsAp", "Simple_Inventory"],
splitKey: bodyshop && bodyshop.imexshopid
});
const { t } = useTranslation();
const { isConnected, scenarioNotificationsOn } = useSocket();
const [notificationVisible, setNotificationVisible] = useState(false);
const baseTitleRef = useRef(document.title || "");
const lastSetTitleRef = useRef("");
const userAssociationId = bodyshop?.associations?.[0]?.id;
const isEmployee = useIsEmployee(bodyshop, currentUser);
const { const {
data: unreadData, data: unreadData,
refetch: refetchUnread, refetch: refetchUnread,
@@ -126,618 +68,286 @@ function Header({
} }
}, [isConnected, unreadLoading, refetchUnread, userAssociationId]); }, [isConnected, unreadLoading, refetchUnread, userAssociationId]);
// Keep The unread count in the title. return { unreadCount, unreadLoading };
}
function useIncompleteTaskCount(assignedToId, bodyshopId, isEmployee, isConnected) {
const { data: taskCountData, loading: taskCountLoading } = useQuery(QUERY_MY_TASKS_COUNT, {
variables: { assigned_to: assignedToId, bodyshopid: bodyshopId },
skip: !assignedToId || !bodyshopId || !isEmployee,
fetchPolicy: "network-only",
pollInterval: isConnected ? 0 : TASKS_CENTER_POLL_INTERVAL
});
const incompleteTaskCount = taskCountData?.tasks_aggregate?.aggregate?.count ?? 0;
return { incompleteTaskCount, taskCountLoading };
}
// --- Main Component ---
function Header(props) {
const {
handleMenuClick,
currentUser,
bodyshop,
selectedHeader,
signOutStart,
setBillEnterContext,
setTimeTicketContext,
setPaymentContext,
setReportCenterContext,
recentItems,
setCardPaymentContext,
setTaskUpsertContext
} = props;
// Feature flags
const {
treatments: { ImEXPay, DmsAp, Simple_Inventory }
} = useSplitTreatments({
attributes: {},
names: ["ImEXPay", "DmsAp", "Simple_Inventory"],
splitKey: bodyshop && bodyshop.imexshopid
});
// Contexts and hooks
const { t } = useTranslation();
const { isConnected, scenarioNotificationsOn } = useSocket();
const [notificationVisible, setNotificationVisible] = useState(false);
const [taskCenterVisible, setTaskCenterVisible] = useState(false);
const baseTitleRef = useRef(document.title || "");
const lastSetTitleRef = useRef("");
const taskCenterRef = useRef(null);
const notificationRef = useRef(null);
const userAssociationId = bodyshop?.associations?.[0]?.id;
const isEmployee = useIsEmployee(bodyshop, currentUser);
// Data hooks
const { unreadCount, unreadLoading } = useUnreadNotifications(
userAssociationId,
isConnected,
scenarioNotificationsOn
);
const assignedToId = bodyshop?.employees?.find((e) => e.user_email === currentUser.email)?.id;
const { incompleteTaskCount, taskCountLoading } = useIncompleteTaskCount(
assignedToId,
bodyshop?.id,
isEmployee,
isConnected
);
// --- Effects ---
// Update document title with unread count
useEffect(() => { useEffect(() => {
const updateTitle = () => { const updateTitle = () => {
const currentTitle = document.title; const currentTitle = document.title;
// Check if the current title differs from what we last set
if (currentTitle !== lastSetTitleRef.current) { if (currentTitle !== lastSetTitleRef.current) {
// Extract base title by removing any unread count prefix
const baseTitleMatch = currentTitle.match(/^\(\d+\)\s*(.*)$/); const baseTitleMatch = currentTitle.match(/^\(\d+\)\s*(.*)$/);
baseTitleRef.current = baseTitleMatch ? baseTitleMatch[1] : currentTitle; baseTitleRef.current = baseTitleMatch ? baseTitleMatch[1] : currentTitle;
} }
// Apply unread count to the base title
const newTitle = unreadCount > 0 ? `(${unreadCount}) ${baseTitleRef.current}` : baseTitleRef.current; const newTitle = unreadCount > 0 ? `(${unreadCount}) ${baseTitleRef.current}` : baseTitleRef.current;
// Only update if the title has changed to avoid unnecessary DOM writes
if (document.title !== newTitle) { if (document.title !== newTitle) {
document.title = newTitle; document.title = newTitle;
lastSetTitleRef.current = newTitle; // Store what we set lastSetTitleRef.current = newTitle;
}
};
updateTitle();
const interval = setInterval(updateTitle, 100);
return () => {
clearInterval(interval);
document.title = baseTitleRef.current;
};
}, [unreadCount]);
// Handle outside clicks for popovers
useEffect(() => {
const handleClickOutside = (event) => {
const isNotificationClick = event.target.closest("#header-notifications");
const isTaskCenterClick = event.target.closest("#header-taskcenter");
if (isNotificationClick && scenarioNotificationsOn) {
setTaskCenterVisible(false); // Close task center
return;
}
if (isTaskCenterClick) {
setNotificationVisible(scenarioNotificationsOn ? false : notificationVisible); // Close notification center if enabled
return;
}
if (taskCenterVisible && taskCenterRef.current && !taskCenterRef.current.contains(event.target)) {
setTaskCenterVisible(false);
}
if (
scenarioNotificationsOn &&
notificationVisible &&
notificationRef.current &&
!notificationRef.current.contains(event.target)
) {
setNotificationVisible(false);
} }
}; };
// Initial update document.addEventListener("mousedown", handleClickOutside);
updateTitle(); return () => document.removeEventListener("mousedown", handleClickOutside);
}, [taskCenterVisible, notificationVisible, scenarioNotificationsOn]);
// Poll every 100ms to catch child component changes // --- Event Handlers ---
const interval = setInterval(updateTitle, 100); const handleTaskCenterClick = useCallback(
(e) => {
setTaskCenterVisible((prev) => {
if (prev) return false;
return true;
});
if (handleMenuClick) handleMenuClick(e);
},
[handleMenuClick]
);
// Cleanup const handleNotificationClick = useCallback(
return () => { (e) => {
clearInterval(interval); setNotificationVisible((prev) => {
document.title = baseTitleRef.current; // Reset to base title on unmount if (prev) return false;
}; return true;
}, [unreadCount]); // Re-run when unreadCount changes });
if (handleMenuClick) handleMenuClick(e);
},
[handleMenuClick]
);
const handleNotificationClick = (e) => { // --- Menu Items ---
setNotificationVisible(!notificationVisible);
if (handleMenuClick) handleMenuClick(e);
};
const accountingChildren = [ // built externally to keep the component clean, but on this level to prevent unnecessary re-renders
{ const accountingChildren = useMemo(
key: "bills", () =>
id: "header-accounting-bills", buildAccountingChildren({
icon: <FaFileInvoiceDollar />, t,
label: ( bodyshop,
<Link to="/manage/bills"> currentUser,
<LockWrapper featureName="bills" bodyshop={bodyshop}> setBillEnterContext,
{t("menus.header.bills")} setPaymentContext,
</LockWrapper> setCardPaymentContext,
</Link> setTimeTicketContext,
) ImEXPay,
}, DmsAp,
{ Simple_Inventory
key: "enterbills", }),
id: "header-accounting-enterbills", [
icon: <GiPayMoney />, t,
label: ( bodyshop,
<LockWrapper featureName="bills" bodyshop={bodyshop}> currentUser,
{t("menus.header.enterbills")} setBillEnterContext,
</LockWrapper> setPaymentContext,
), setCardPaymentContext,
onClick: () => setTimeTicketContext,
HasFeatureAccess({ featureName: "bills", bodyshop }) && ImEXPay,
setBillEnterContext({ DmsAp,
actions: {}, Simple_Inventory
context: {} ]
}) );
},
...(Simple_Inventory.treatment === "on" // Built externally to keep the component clean
? [ const leftMenuItems = useMemo(
{ type: "divider" }, () =>
{ buildLeftMenuItems({
key: "inventory", t,
id: "header-accounting-inventory", bodyshop,
icon: <FaFileInvoiceDollar />, recentItems,
label: <Link to="/manage/inventory">{t("menus.header.inventory")}</Link> setTaskUpsertContext,
} setReportCenterContext,
] signOutStart,
: []), accountingChildren
{ type: "divider" }, }),
{ [t, bodyshop, recentItems, setTaskUpsertContext, setReportCenterContext, signOutStart, accountingChildren]
key: "allpayments", );
id: "header-accounting-allpayments",
icon: <BankFilled />, const rightMenuItems = useMemo(() => {
label: <Link to="/manage/payments">{t("menus.header.allpayments")}</Link> const items = [];
}, if (scenarioNotificationsOn) {
{ items.push({
key: "enterpayments", key: "notifications",
id: "header-accounting-enterpayments", id: "header-notifications",
icon: <FaCreditCard />, icon: unreadLoading ? (
label: t("menus.header.enterpayment"), <Spin size="small" />
onClick: () => ) : (
setPaymentContext({ <Badge offset={[8, 0]} size="small" count={isEmployee ? unreadCount : 0}>
actions: {}, <BellFilled />
context: null </Badge>
}) ),
}, onClick: handleNotificationClick
...(ImEXPay.treatment === "on" });
? [
{
key: "entercardpayments",
id: "header-accounting-entercardpayments",
icon: <FaCreditCard />,
label: t("menus.header.entercardpayment"),
onClick: () => setCardPaymentContext({ actions: {}, context: {} })
}
]
: []),
{ type: "divider" },
{
key: "timetickets",
id: "header-accounting-timetickets",
icon: <FieldTimeOutlined />,
label: (
<Link to="/manage/timetickets">
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
{t("menus.header.timetickets")}
</LockWrapper>
</Link>
)
},
...(bodyshop?.md_tasks_presets?.use_approvals
? [
{
key: "ttapprovals",
id: "header-accounting-ttapprovals",
icon: <FieldTimeOutlined />,
label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link>
}
]
: []),
{
key: "entertimetickets",
id: "header-accounting-entertimetickets",
icon: <GiPlayerTime />,
label: (
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
{t("menus.header.entertimeticket")}
</LockWrapper>
),
onClick: () =>
HasFeatureAccess({ featureName: "timetickets", bodyshop }) &&
setTimeTicketContext({
actions: {},
context: {
created_by: currentUser.displayName
? `${currentUser.email} | ${currentUser.displayName}`
: currentUser.email
}
})
},
{ type: "divider" },
{
key: "accountingexport",
id: "header-accounting-export",
icon: <ExportOutlined />,
label: (
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export")}
</LockWrapper>
),
children: [
{
key: "receivables",
id: "header-accounting-receivables",
label: (
<Link to="/manage/accounting/receivables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-receivables")}
</LockWrapper>
</Link>
)
},
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) ||
DmsAp.treatment === "on"
? [
{
key: "payables",
id: "header-accounting-payables",
label: (
<Link to="/manage/accounting/payables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payables")}
</LockWrapper>
</Link>
)
}
]
: []),
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber))
? [
{
key: "payments",
id: "header-accounting-payments",
label: (
<Link to="/manage/accounting/payments">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payments")}
</LockWrapper>
</Link>
)
}
]
: []),
{ type: "divider" },
{
key: "exportlogs",
id: "header-accounting-exportlogs",
label: (
<Link to="/manage/accounting/exportlogs">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export-logs")}
</LockWrapper>
</Link>
)
}
]
} }
]; items.push({
key: "taskcenter",
// Left menu items (includes original navigation items) id: "header-taskcenter",
const leftMenuItems = [ icon: taskCountLoading ? (
{ <Spin size="small" />
key: "home", ) : (
id: "header-home", <Badge offset={[8, 0]} size="small" count={incompleteTaskCount > 0 ? incompleteTaskCount : 0} showZero={false}>
icon: <HomeFilled />, <Tooltip title={t("menus.header.tasks")}>
label: <Link to="/manage/">{t("menus.header.home")}</Link> <FaTasks />
}, </Tooltip>
{ </Badge>
key: "schedule",
id: "header-schedule",
icon: <FaCalendarAlt />,
label: <Link to="/manage/schedule">{t("menus.header.schedule")}</Link>
},
{
key: "jobssubmenu",
id: "header-jobs",
icon: <FaCarCrash />,
label: t("menus.header.jobs"),
children: [
{
key: "activejobs",
id: "header-active-jobs",
icon: <FileFilled />,
label: <Link to="/manage/jobs">{t("menus.header.activejobs")}</Link>
},
{
key: "readyjobs",
id: "header-ready-jobs",
icon: <CheckCircleOutlined />,
label: <Link to="/manage/jobs/ready">{t("menus.header.readyjobs")}</Link>
},
{
key: "parts-queue",
id: "header-parts-queue",
icon: <ToolFilled />,
label: <Link to="/manage/partsqueue">{t("menus.header.parts-queue")}</Link>
},
{
key: "availablejobs",
id: "header-jobs-available",
icon: <ImportOutlined />,
label: <Link to="/manage/available">{t("menus.header.availablejobs")}</Link>
},
{
key: "newjob",
id: "header-new-job",
icon: <FileAddOutlined />,
label: <Link to="/manage/jobs/new">{t("menus.header.newjob")}</Link>
},
{ type: "divider" },
{
key: "alljobs",
id: "header-all-jobs",
icon: <UnorderedListOutlined />,
label: <Link to="/manage/jobs/all">{t("menus.header.alljobs")}</Link>
},
{ type: "divider" },
{
key: "productionlist",
id: "header-production-list",
icon: <ScheduleOutlined />,
label: <Link to="/manage/production/list">{t("menus.header.productionlist")}</Link>
},
{
key: "productionboard",
id: "header-production-board",
icon: <BsKanban />,
label: (
<Link to="/manage/production/board">
<LockWrapper featureName="visualboard" bodyshop={bodyshop}>
{t("menus.header.productionboard")}
</LockWrapper>
</Link>
)
},
{ type: "divider" },
{
key: "scoreboard",
id: "header-scoreboard",
icon: <LineChartOutlined />,
label: (
<Link to="/manage/scoreboard">
<LockWrapper featureName="scoreboard" bodyshop={bodyshop}>
{t("menus.header.scoreboard")}
</LockWrapper>
</Link>
)
}
]
},
{
key: "customers",
id: "header-customers",
icon: <UserOutlined />,
label: t("menus.header.customers"),
children: [
{
key: "owners",
id: "header-owners",
icon: <TeamOutlined />,
label: <Link to="/manage/owners">{t("menus.header.owners")}</Link>
},
{
key: "vehicles",
id: "header-vehicles",
icon: <CarFilled />,
label: <Link to="/manage/vehicles">{t("menus.header.vehicles")}</Link>
}
]
},
{
key: "ccs",
id: "header-css",
icon: <CarFilled />,
label: (
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars")}
</LockWrapper>
), ),
children: [ onClick: handleTaskCenterClick
{ });
key: "courtesycarsall", return items;
id: "header-courtesycars-all", }, [
icon: <CarFilled />, scenarioNotificationsOn,
label: ( unreadLoading,
<Link to="/manage/courtesycars"> unreadCount,
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}> taskCountLoading,
{t("menus.header.courtesycars-all")} incompleteTaskCount,
</LockWrapper> isEmployee,
</Link> handleNotificationClick,
) handleTaskCenterClick,
}, t
{ ]);
key: "contracts",
id: "header-contracts",
icon: <FileFilled />,
label: (
<Link to="/manage/courtesycars/contracts">
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars-contracts")}
</LockWrapper>
</Link>
)
},
{
key: "newcontract",
id: "header-newcontract",
icon: <FileAddFilled />,
label: (
<Link to="/manage/courtesycars/contracts/new">
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars-newcontract")}
</LockWrapper>
</Link>
)
}
]
},
...(accountingChildren.length > 0
? [
{
key: "accounting",
id: "header-accounting",
icon: <DollarCircleFilled />,
label: t("menus.header.accounting"),
children: accountingChildren
}
]
: []),
{
key: "phonebook",
id: "header-phonebook",
icon: <PhoneOutlined />,
label: <Link to="/manage/phonebook">{t("menus.header.phonebook")}</Link>
},
{
key: "temporarydocs",
id: "header-temporarydocs",
icon: <PaperClipOutlined />,
label: (
<Link to="/manage/temporarydocs">
<LockWrapper featureName="media" bodyshop={bodyshop}>
{t("menus.header.temporarydocs")}
</LockWrapper>
</Link>
)
},
{
key: "tasks",
id: "tasks",
icon: <FaTasks />,
label: t("menus.header.tasks"),
children: [
{
key: "createTask",
id: "header-create-task",
icon: <PlusCircleOutlined />,
label: t("menus.header.create_task"),
onClick: () => setTaskUpsertContext({ actions: {}, context: {} })
},
{
key: "mytasks",
id: "header-my-tasks",
icon: <FaTasks />,
label: <Link to="/manage/tasks/mytasks">{t("menus.header.my_tasks")}</Link>
},
{
key: "all_tasks",
id: "header-all-tasks",
icon: <FaTasks />,
label: <Link to="/manage/tasks/alltasks">{t("menus.header.all_tasks")}</Link>
}
]
},
{
key: "shopsubmenu",
id: "header-shopsubmenu",
icon: <SettingOutlined />,
label: t("menus.header.shop"),
children: [
{
key: "shop",
id: "header-shop",
icon: <GiSettingsKnobs />,
label: <Link to="/manage/shop?tab=info">{t("menus.header.shop_config")}</Link>
},
{
key: "dashboard",
id: "header-dashboard",
icon: <DashboardFilled />,
label: (
<Link to="/manage/dashboard">
<LockWrapper featureName="bills">{t("menus.header.dashboard")}</LockWrapper>
</Link>
)
},
{
key: "reportcenter",
id: "header-reportcenter",
icon: <BarChartOutlined />,
label: t("menus.header.reportcenter"),
onClick: () => setReportCenterContext({ actions: {}, context: {} })
},
{
key: "shop-vendors",
id: "header-shop-vendors",
icon: <IoBusinessOutline />,
label: <Link to="/manage/shop/vendors">{t("menus.header.shop_vendors")}</Link>
},
{
key: "shop-csi",
id: "header-shop-csi",
icon: <RiSurveyLine />,
label: (
<Link to="/manage/shop/csi">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.shop_csi")}
</LockWrapper>
</Link>
)
}
]
},
{
key: "recent",
id: "header-recent",
icon: <ClockCircleFilled />,
label: t("menus.header.recent"),
children: recentItems.map((i, idx) => ({
key: idx,
id: `header-recent-${idx}`,
label: <Link to={i.url}>{i.label}</Link>
}))
},
{
key: "user",
id: "header-user",
icon: <UserOutlined />,
label: t("menus.currentuser.profile"),
children: [
{
key: "signout",
id: "header-signout",
icon: <FiLogOut />,
danger: true,
label: t("user.actions.signout"),
onClick: () => signOutStart()
},
{
key: "help",
id: "header-help",
icon: <QuestionCircleFilled />,
label: t("menus.header.help"),
onClick: () => window.open("https://help.imex.online/", "_blank")
},
...(InstanceRenderManager({ imex: true, rome: false })
? [
{
key: "rescue",
id: "header-rescue",
icon: <CarFilled />,
label: t("menus.header.rescueme"),
onClick: () => window.open("https://imexrescue.com/", "_blank")
}
]
: []),
{
key: "shiftclock",
id: "header-shiftclock",
icon: <GiPlayerTime />,
label: (
<Link to="/manage/shiftclock">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.shiftclock")}
</LockWrapper>
</Link>
)
},
{
key: "profile",
id: "header-profile",
icon: <UserOutlined />,
label: <Link to="/manage/profile">{t("menus.currentuser.profile")}</Link>
}
]
}
];
// Notifications item (always on the right)
const notificationItem = scenarioNotificationsOn
? [
{
key: "notifications",
id: "header-notifications",
icon: unreadLoading ? (
<Spin size="small" />
) : (
<Badge offset={[8, 0]} size="small" count={isEmployee ? unreadCount : 0}>
<BellFilled />
</Badge>
),
onClick: handleNotificationClick
}
]
: [];
// --- Render ---
return ( return (
<Layout.Header style={{ padding: 0, background: "#001529" }}> <Layout.Header style={{ padding: 0, background: "#001529" }}>
<div <div style={{ display: "flex", alignItems: "center", height: "100%", overflow: "hidden" }}>
style={{ <div style={{ flexGrow: 1, overflowX: "auto", whiteSpace: "nowrap" }}>
display: "flex",
justifyContent: "space-between",
alignItems: "center",
height: "100%",
overflow: "hidden"
}}
>
<Menu
mode="horizontal"
theme="dark"
selectedKeys={[selectedHeader]}
onClick={handleMenuClick}
subMenuCloseDelay={0.3}
items={leftMenuItems}
style={{
flex: "1 1 auto",
minWidth: 0,
overflowX: "auto",
borderBottom: "none",
background: "transparent"
}}
/>
{scenarioNotificationsOn && (
<Menu <Menu
mode="horizontal" mode="horizontal"
theme="dark" theme="dark"
selectedKeys={[selectedHeader]} selectedKeys={[selectedHeader]}
onClick={handleMenuClick} onClick={handleMenuClick}
subMenuCloseDelay={0.3} subMenuCloseDelay={0.3}
items={notificationItem} items={leftMenuItems}
style={{ flex: "0 0 auto", minWidth: 0, borderBottom: "none", background: "transparent" }} style={{ borderBottom: "none", background: "transparent", minWidth: "100%" }}
/> />
)} </div>
<div style={{ width: 120, flexShrink: 0 }}>
<Menu
mode="horizontal"
theme="dark"
selectedKeys={[selectedHeader]}
onClick={handleMenuClick}
subMenuCloseDelay={0.3}
items={rightMenuItems}
style={{ borderBottom: "none", background: "transparent", justifyContent: "flex-end" }}
/>
</div>
</div> </div>
{scenarioNotificationsOn && ( {scenarioNotificationsOn && (
<NotificationCenterContainer <div ref={notificationRef}>
visible={notificationVisible} <NotificationCenterContainer
onClose={() => setNotificationVisible(false)} visible={notificationVisible}
unreadCount={unreadCount} onClose={() => setNotificationVisible(false)}
/> unreadCount={unreadCount}
/>
</div>
)} )}
<div ref={taskCenterRef}>
<TaskCenterContainer
incompleteTaskCount={incompleteTaskCount}
visible={taskCenterVisible}
onClose={() => setTaskCenterVisible(false)}
/>
</div>
</Layout.Header> </Layout.Header>
); );
} }

View File

@@ -385,7 +385,9 @@ export function ScheduleEventComponent({
previousEvent: event.id, previousEvent: event.id,
color: event.color, color: event.color,
alt_transport: event.job && event.job.alt_transport, alt_transport: event.job && event.job.alt_transport,
note: event.note note: event.note,
scheduled_in: event.job && event.job.scheduled_in,
scheduled_completion: event.job && event.job.scheduled_completion
} }
}); });
}} }}
@@ -395,32 +397,33 @@ export function ScheduleEventComponent({
) : ( ) : (
<ScheduleManualEvent event={event} /> <ScheduleManualEvent event={event} />
)} )}
{event.isintake && HasFeatureAccess({ featureName: "checklist", bodyshop }) ? ( {event.job &&
<Link (HasFeatureAccess({ featureName: "checklist", bodyshop }) ? (
to={{ <Link
pathname: `/manage/jobs/${event.job && event.job.id}/intake`, to={{
search: `?appointmentId=${event.id}` pathname: `/manage/jobs/${event.job && event.job.id}/intake`,
}} search: `?appointmentId=${event.id}`
> }}
<Button disabled={event.arrived}>{t("appointments.actions.intake")}</Button> >
</Link> <Button disabled={event.arrived}>{t("appointments.actions.intake")}</Button>
) : ( </Link>
<Popover //open={open} ) : (
content={popMenu} <Popover //open={open}
open={popOverVisible} content={popMenu}
onOpenChange={setPopOverVisible} open={popOverVisible}
onClick={(e) => { onOpenChange={setPopOverVisible}
if (event.job?.id) { onClick={(e) => {
e.stopPropagation(); if (event.job?.id) {
getJobDetails(); e.stopPropagation();
} getJobDetails();
}} }
getPopupContainer={(trigger) => trigger.parentNode} }}
trigger="click" getPopupContainer={(trigger) => trigger.parentNode}
> trigger="click"
<Button disabled={event.arrived}>{t("jobs.actions.intake_quick")}</Button> >
</Popover> <Button disabled={event.arrived}>{t("jobs.actions.intake_quick")}</Button>
)} </Popover>
))}
</Space> </Space>
</div> </div>
); );

View File

@@ -1,7 +1,7 @@
import { useMutation } from "@apollo/client"; import { useMutation } from "@apollo/client";
import { Button, Card, Form, Input, Switch } from "antd"; import { Button, Card, Form, Input, Switch } from "antd";
import queryString from "query-string"; import queryString from "query-string";
import React, { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useLocation, useNavigate, useParams } from "react-router-dom";
@@ -9,7 +9,6 @@ import { createStructuredSelector } from "reselect";
import { logImEXEvent } from "../../../../firebase/firebase.utils"; import { logImEXEvent } from "../../../../firebase/firebase.utils";
import { MARK_APPOINTMENT_ARRIVED, MARK_LATEST_APPOINTMENT_ARRIVED } from "../../../../graphql/appointments.queries"; import { MARK_APPOINTMENT_ARRIVED, MARK_LATEST_APPOINTMENT_ARRIVED } from "../../../../graphql/appointments.queries";
import { UPDATE_JOB } from "../../../../graphql/jobs.queries"; import { UPDATE_JOB } from "../../../../graphql/jobs.queries";
import { UPDATE_OWNER } from "../../../../graphql/owners.queries";
import { insertAuditTrail } from "../../../../redux/application/application.actions"; import { insertAuditTrail } from "../../../../redux/application/application.actions";
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";
@@ -32,7 +31,6 @@ export function JobChecklistForm({ insertAuditTrail, formItems, bodyshop, curren
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [markAptArrived] = useMutation(MARK_APPOINTMENT_ARRIVED); const [markAptArrived] = useMutation(MARK_APPOINTMENT_ARRIVED);
const [markLatestAptArrived] = useMutation(MARK_LATEST_APPOINTMENT_ARRIVED); const [markLatestAptArrived] = useMutation(MARK_LATEST_APPOINTMENT_ARRIVED);
const [updateOwner] = useMutation(UPDATE_OWNER);
const notification = useNotification(); const notification = useNotification();
const { jobId } = useParams(); const { jobId } = useParams();
@@ -129,24 +127,6 @@ export function JobChecklistForm({ insertAuditTrail, formItems, bodyshop, curren
} }
} }
if (type === "intake" && job.owner && job.owner.id) {
//Updae Owner Allow to Text
const updateOwnerResult = await updateOwner({
variables: {
ownerId: job.owner.id,
owner: { allow_text_message: values.allow_text_message }
}
});
if (!!updateOwnerResult.errors) {
notification["error"]({
message: t("checklist.errors.complete", {
error: JSON.stringify(result.errors)
})
});
}
}
setLoading(false); setLoading(false);
if (!!!result.errors) { if (!!!result.errors) {
@@ -189,7 +169,6 @@ export function JobChecklistForm({ insertAuditTrail, formItems, bodyshop, curren
initialValues={{ initialValues={{
...(type === "intake" && { ...(type === "intake" && {
addToProduction: true, addToProduction: true,
allow_text_message: job.owner && job.owner.allow_text_message,
scheduled_completion: scheduled_completion:
(job && job.scheduled_completion && dayjs(job.scheduled_completion)) || (job && job.scheduled_completion && dayjs(job.scheduled_completion)) ||
(job && (job &&
@@ -228,14 +207,6 @@ export function JobChecklistForm({ insertAuditTrail, formItems, bodyshop, curren
> >
<Switch disabled={readOnly} /> <Switch disabled={readOnly} />
</Form.Item> </Form.Item>
<Form.Item
name="allow_text_message"
valuePropName="checked"
label={t("checklist.labels.allow_text_message")}
disabled={readOnly}
>
<Switch disabled={readOnly} />
</Form.Item>
<Form.Item <Form.Item
name="scheduled_completion" name="scheduled_completion"
label={t("jobs.fields.scheduled_completion")} label={t("jobs.fields.scheduled_completion")}

View File

@@ -80,7 +80,7 @@ export function JobEmployeeAssignments({
); );
return ( return (
<Popover destroyTooltipOnHide content={popContent} open={visibility}> <Popover destroyOnHidden content={popContent} open={visibility}>
<Spin spinning={loading}> <Spin spinning={loading}>
<DataLabel label={t("jobs.fields.employee_body")}> <DataLabel label={t("jobs.fields.employee_body")}>
{body ? ( {body ? (

View File

@@ -1,19 +1,19 @@
import { DownloadOutlined, SyncOutlined } from "@ant-design/icons"; import { DownloadOutlined, SyncOutlined } from "@ant-design/icons";
import { Button, Card, Input, Space, Table } from "antd"; import { Button, Card, Input, Space, Table } from "antd";
import axios from "axios"; import axios from "axios";
import React, { useState } from "react"; import { 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 { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { selectPartnerVersion } from "../../redux/application/application.selectors"; import { selectPartnerVersion } from "../../redux/application/application.selectors";
import { alphaSort } from "../../utils/sorters"; import { alphaSort } from "../../utils/sorters";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser //currentUser: selectCurrentUser
partnerVersion: selectPartnerVersion partnerVersion: selectPartnerVersion
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export default connect(mapStateToProps, mapDispatchToProps)(JobsAvailableScan); export default connect(mapStateToProps, mapDispatchToProps)(JobsAvailableScan);
@@ -126,6 +126,7 @@ export function JobsAvailableScan({ partnerVersion, refetch }) {
onClick={() => { onClick={() => {
scanEstimates(); scanEstimates();
}} }}
id="scan-estimates-button"
> >
<SyncOutlined /> <SyncOutlined />
</Button> </Button>

View File

@@ -1,4 +1,4 @@
import { Form, Input, Switch } from "antd"; import { Form, Input } from "antd";
import React, { useContext } from "react"; import React, { useContext } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import JobCreateContext from "../../pages/jobs-create/jobs-create.context"; import JobCreateContext from "../../pages/jobs-create/jobs-create.context";
@@ -129,13 +129,6 @@ export default function JobsCreateOwnerInfoNewComponent() {
<Form.Item label={t("owners.fields.preferred_contact")} name={["owner", "data", "preferred_contact"]}> <Form.Item label={t("owners.fields.preferred_contact")} name={["owner", "data", "preferred_contact"]}>
<Input disabled={!state.owner.new} /> <Input disabled={!state.owner.new} />
</Form.Item> </Form.Item>
<Form.Item
label={t("owners.fields.allow_text_message")}
valuePropName="checked"
name={["owner", "data", "allow_text_message"]}
>
<Switch disabled={!state.owner.new} />
</Form.Item>
</LayoutFormRow> </LayoutFormRow>
</div> </div>
); );

View File

@@ -1,5 +1,5 @@
import { Card, Input, Table } from "antd"; import { Card, Input, Table } from "antd";
import React, { useContext, useState } from "react"; import { useContext, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import JobCreateContext from "../../pages/jobs-create/jobs-create.context"; import JobCreateContext from "../../pages/jobs-create/jobs-create.context";
import PhoneFormatter from "../../utils/PhoneFormatter"; import PhoneFormatter from "../../utils/PhoneFormatter";
@@ -91,6 +91,7 @@ export default function JobsCreateOwnerInfoSearchComponent({ loading, owners })
}); });
}} }}
enterButton enterButton
id="search-owner"
/> />
} }
> >
@@ -112,9 +113,9 @@ export default function JobsCreateOwnerInfoSearchComponent({ loading, owners })
type: "radio", type: "radio",
selectedRowKeys: [state.owner.selectedid] selectedRowKeys: [state.owner.selectedid]
}} }}
onRow={(record, rowIndex) => { onRow={(record) => {
return { return {
onClick: (event) => { onClick: () => {
if (record) { if (record) {
if (record.id) { if (record.id) {
setState({ setState({

View File

@@ -72,7 +72,7 @@ export default function JobsCreateVehicleInfoPredefined({ disabled, form }) {
open={open} open={open}
placement="left" placement="left"
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
destroyTooltipOnHide destroyOnHidden
> >
<SearchOutlined style={{ cursor: "pointer" }} /> <SearchOutlined style={{ cursor: "pointer" }} />
</Popover> </Popover>

View File

@@ -1,9 +1,9 @@
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { Card, Input, Space, Table } from "antd"; import { Card, Input, Space, Table } from "antd";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { alphaSort } from "../../utils/sorters";
import JobCreateContext from "../../pages/jobs-create/jobs-create.context"; import JobCreateContext from "../../pages/jobs-create/jobs-create.context";
import { alphaSort } from "../../utils/sorters";
import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component"; import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component";
export default function JobsCreateVehicleInfoSearchComponent({ loading, vehicles }) { export default function JobsCreateVehicleInfoSearchComponent({ loading, vehicles }) {
@@ -63,6 +63,7 @@ export default function JobsCreateVehicleInfoSearchComponent({ loading, vehicles
}); });
}} }}
enterButton enterButton
id="search-vehicle"
/> />
</Space> </Space>
} }
@@ -91,9 +92,9 @@ export default function JobsCreateVehicleInfoSearchComponent({ loading, vehicles
type: "radio", type: "radio",
selectedRowKeys: [state.vehicle.selectedid] selectedRowKeys: [state.vehicle.selectedid]
}} }}
onRow={(record, rowIndex) => { onRow={(record) => {
return { return {
onClick: (event) => { onClick: () => {
if (record) { if (record) {
if (record.id) { if (record.id) {
setState({ setState({

View File

@@ -1,13 +1,13 @@
import { Form, Statistic, Tooltip } from "antd"; import { Form, Statistic, Tooltip } from "antd";
import React, { useMemo } from "react"; import { useMemo } 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 { selectJobReadOnly } from "../../redux/application/application.selectors"; import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import dayjs from "../../utils/day";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component"; import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component";
import FormRow from "../layout-form-row/layout-form-row.component"; import FormRow from "../layout-form-row/layout-form-row.component";
import dayjs from "../../utils/day";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly, jobRO: selectJobReadOnly,
@@ -43,14 +43,14 @@ export function JobsDetailDatesComponent({ jobRO, job, bodyshop }) {
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.estimate_sent_approval")} name="estimate_sent_approval"> <Form.Item label={t("jobs.fields.estimate_sent_approval")} name="estimate_sent_approval">
<DateTimePicker <DateTimePicker
disabled={true} disabled={jobRO}
value={job.estimate_sent_approval ? dayjs(job.estimate_sent_approval) : null} value={job.estimate_sent_approval ? dayjs(job.estimate_sent_approval) : null}
placeholder={t("general.labels.na")} placeholder={t("general.labels.na")}
/> />
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.estimate_approved")} name="estimate_approved"> <Form.Item label={t("jobs.fields.estimate_approved")} name="estimate_approved">
<DateTimePicker <DateTimePicker
disabled={true} disabled={jobRO}
value={job.estimate_approved ? dayjs(job.estimate_approved) : null} value={job.estimate_approved ? dayjs(job.estimate_approved) : null}
placeholder={t("general.labels.na")} placeholder={t("general.labels.na")}
/> />
@@ -63,7 +63,7 @@ export function JobsDetailDatesComponent({ jobRO, job, bodyshop }) {
</Form.Item> </Form.Item>
<Tooltip title={t("jobs.labels.scheduledinchange")}> <Tooltip title={t("jobs.labels.scheduledinchange")}>
<Form.Item label={t("jobs.fields.scheduled_in")} name="scheduled_in"> <Form.Item label={t("jobs.fields.scheduled_in")} name="scheduled_in">
<DateTimePicker disabled={true || jobRO} /> <DateTimePicker disabled={true} />
</Form.Item> </Form.Item>
</Tooltip> </Tooltip>
<Form.Item label={t("jobs.fields.actual_in")} name="actual_in"> <Form.Item label={t("jobs.fields.actual_in")} name="actual_in">
@@ -110,16 +110,16 @@ export function JobsDetailDatesComponent({ jobRO, job, bodyshop }) {
</FormRow> </FormRow>
<FormRow header={t("jobs.forms.admindates")}> <FormRow header={t("jobs.forms.admindates")}>
<Form.Item label={t("jobs.fields.date_invoiced")} name="date_invoiced"> <Form.Item label={t("jobs.fields.date_invoiced")} name="date_invoiced">
<DateTimePicker disabled={true || jobRO} /> <DateTimePicker disabled={true} />
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.date_exported")} name="date_exported"> <Form.Item label={t("jobs.fields.date_exported")} name="date_exported">
<DateTimePicker disabled={true || jobRO} /> <DateTimePicker disabled={true} />
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.date_void")} name="date_void"> <Form.Item label={t("jobs.fields.date_void")} name="date_void">
<DateTimePicker disabled={true || jobRO} /> <DateTimePicker disabled={true} />
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.date_lost_sale")} name="date_lost_sale"> <Form.Item label={t("jobs.fields.date_lost_sale")} name="date_lost_sale">
<DateTimePicker disabled={true || jobRO} /> <DateTimePicker disabled={true} />
</Form.Item> </Form.Item>
</FormRow> </FormRow>
</div> </div>

View File

@@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { auth, logImEXEvent } from "../../firebase/firebase.utils"; import { auth, logImEXEvent } from "../../firebase/firebase.utils";
import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries"; import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries";
@@ -32,7 +33,6 @@ import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
import AddToProduction from "./jobs-detail-header-actions.addtoproduction.util"; import AddToProduction from "./jobs-detail-header-actions.addtoproduction.util";
import DuplicateJob from "./jobs-detail-header-actions.duplicate.util"; import DuplicateJob from "./jobs-detail-header-actions.duplicate.util";
import JobsDetailHeaderActionsToggleProduction from "./jobs-detail-header-actions.toggle-production"; import JobsDetailHeaderActionsToggleProduction from "./jobs-detail-header-actions.toggle-production";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -673,7 +673,9 @@ export function JobsDetailHeaderActions({
context: { context: {
jobId: job.id, jobId: job.id,
job: job, job: job,
alt_transport: job.alt_transport alt_transport: job.alt_transport,
scheduled_in: job.scheduled_in,
scheduled_completion: job.scheduled_completion
} }
}); });
} }
@@ -1078,17 +1080,22 @@ export function JobsDetailHeaderActions({
menuItems.push({ menuItems.push({
key: "deletejob", key: "deletejob",
id: "job-actions-deletejob", id: "job-actions-deletejob",
label: ( label:
<Popconfirm job.job_watchers.length === 0 ? (
title={t("jobs.labels.deleteconfirm")} <Popconfirm
okText={t("general.labels.yes")} title={t("jobs.labels.deleteconfirm")}
cancelText={t("general.labels.no")} okText={t("general.labels.yes")}
onClick={(e) => e.stopPropagation()} cancelText={t("general.labels.no")}
onConfirm={handleDeleteJob} onClick={(e) => e.stopPropagation()}
> onConfirm={handleDeleteJob}
{t("menus.jobsactions.deletejob")} >
</Popconfirm> {t("menus.jobsactions.deletejob")}
) </Popconfirm>
) : (
<Popconfirm title={t("jobs.labels.deletewatchers")} onClick={(e) => e.stopPropagation()} showCancel={false}>
{t("menus.jobsactions.deletejob")}
</Popconfirm>
)
}); });
} }
@@ -1109,8 +1116,8 @@ export function JobsDetailHeaderActions({
<RbacWrapper action="jobs:void" noauth> <RbacWrapper action="jobs:void" noauth>
<Popconfirm <Popconfirm
title={t("jobs.labels.voidjob")} title={t("jobs.labels.voidjob")}
okText="Yes" okText={t("general.labels.yes")}
cancelText="No" cancelText={t("general.labels.no")}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onConfirm={handleVoidJob} onConfirm={handleVoidJob}
> >

View File

@@ -167,7 +167,18 @@ export function JobsDetailHeaderActionsToggleProduction({
<FormDateTimePickerComponent disabled={jobRO} /> <FormDateTimePickerComponent disabled={jobRO} />
</Form.Item> </Form.Item>
<Form.Item name={["actual_delivery"]} label={t("jobs.fields.actual_delivery")}> <Form.Item
name={["actual_delivery"]}
label={t("jobs.fields.actual_delivery")}
rules={[
{
required: bodyshop.deliverchecklist.actual_delivery
? bodyshop.deliverchecklist.actual_delivery
: false
//message: t("general.validation.required"),
}
]}
>
<FormDateTimePickerComponent disabled={jobRO} /> <FormDateTimePickerComponent disabled={jobRO} />
</Form.Item> </Form.Item>
</> </>

View File

@@ -1,17 +1,20 @@
import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined, WarningFilled } from "@ant-design/icons"; import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined, WarningFilled } from "@ant-design/icons";
import { useMutation } from "@apollo/client";
import { Card, Checkbox, Col, Divider, Row, Space, Tag, Tooltip } from "antd"; import { Card, Checkbox, Col, Divider, Row, Space, Tag, Tooltip } from "antd";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useMutation } from "@apollo/client";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { UPDATE_JOB } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions.js";
import { selectJobReadOnly } from "../../redux/application/application.selectors"; import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions"; import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import { UPDATE_JOB } from "../../graphql/jobs.queries"; import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
import CurrencyFormatter from "../../utils/CurrencyFormatter"; import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateTimeFormatter } from "../../utils/DateFormatter"; import { DateTimeFormatter, DateTimeFormatterFunction } from "../../utils/DateFormatter";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import PhoneNumberFormatter from "../../utils/PhoneFormatter"; import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import ChatOpenButton from "../chat-open-button/chat-open-button.component"; import ChatOpenButton from "../chat-open-button/chat-open-button.component";
@@ -24,7 +27,6 @@ import ProductionListColumnComment from "../production-list-columns/production-l
import ProductionListColumnProductionNote from "../production-list-columns/production-list-columns.productionnote.component"; import ProductionListColumnProductionNote from "../production-list-columns/production-list-columns.productionnote.component";
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 { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly, jobRO: selectJobReadOnly,
@@ -38,6 +40,14 @@ const mapDispatchToProps = (dispatch) => ({
context: context, context: context,
modal: "printCenter" modal: "printCenter"
}) })
),
insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
insertAuditTrail({
jobid,
operation,
type
})
) )
}); });
@@ -49,7 +59,7 @@ const colSpan = {
xl: { span: 6 } xl: { span: 6 }
}; };
export function JobsDetailHeader({ job, bodyshop, disabled }) { export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { notification } = useNotification(); const { notification } = useNotification();
const [notesClamped, setNotesClamped] = useState(true); const [notesClamped, setNotesClamped] = useState(true);
@@ -66,7 +76,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
const handleCheckboxChange = async (field, checked) => { const handleCheckboxChange = async (field, checked) => {
const value = checked ? dayjs().toISOString() : null; const value = checked ? dayjs().toISOString() : null;
try { try {
await updateJob({ const ret = await updateJob({
variables: { variables: {
jobId: job.id, jobId: job.id,
job: { [field]: value } job: { [field]: value }
@@ -74,6 +84,16 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
refetchQueries: ["GET_JOB_BY_PK"], refetchQueries: ["GET_JOB_BY_PK"],
awaitRefetchQueries: true awaitRefetchQueries: true
}); });
insertAuditTrail({
jobid: job.id,
operation: AuditTrailMapping.jobfieldchange(
field,
ret.data.update_jobs.returning[0][field]
? DateTimeFormatterFunction(ret.data.update_jobs.returning[0][field])
: checked
),
type: "jobfieldchange"
});
} catch (error) { } catch (error) {
notification.error({ notification.error({
message: t("jobs.errors.saving", { error: error.message }) message: t("jobs.errors.saving", { error: error.message })
@@ -315,7 +335,11 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
</Card> </Card>
</Col> </Col>
<Col {...colSpan}> <Col {...colSpan}>
<Card style={{ height: "100%" }} title={t("jobs.labels.employeeassignments")}> <Card
style={{ height: "100%" }}
title={<span id="job-employee-assignments-title">{t("jobs.labels.employeeassignments")}</span>}
id={"job-employee-assignments"}
>
<div> <div>
<JobEmployeeAssignments job={job} /> <JobEmployeeAssignments job={job} />
<Divider style={{ margin: ".5rem" }} /> <Divider style={{ margin: ".5rem" }} />

View File

@@ -42,7 +42,7 @@ export function JobsDocumentsContainer({
variables: { jobId: jobId }, variables: { jobId: jobId },
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only", nextFetchPolicy: "network-only",
skip: Imgproxy.treatment === "on" || !!billId skip: !!billId
}); });
if (loading) return <LoadingSpinner />; if (loading) return <LoadingSpinner />;

View File

@@ -3,7 +3,6 @@ import axios from "axios";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
import cleanAxios from "../../utils/CleanAxios";
import formatBytes from "../../utils/formatbytes"; import formatBytes from "../../utils/formatbytes";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -12,7 +11,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
@@ -26,7 +25,7 @@ const mapDispatchToProps = (dispatch) => ({
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyDownloadButton); export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyDownloadButton);
export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, identifier, jobId }) { export function JobsDocumentsImgproxyDownloadButton({ galleryImages, identifier, jobId }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [download, setDownload] = useState(null); const [download, setDownload] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -46,32 +45,40 @@ export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, i
} }
function standardMediaDownload(bufferData) { function standardMediaDownload(bufferData) {
const a = document.createElement("a"); try {
const url = window.URL.createObjectURL(new Blob([bufferData])); const a = document.createElement("a");
a.href = url; const url = window.URL.createObjectURL(new Blob([bufferData]));
a.download = `${identifier || "documents"}.zip`; a.href = url;
a.click(); a.download = `${identifier || "documents"}.zip`;
a.click();
} catch (error) {
setLoading(false);
setDownload(null);
}
} }
const handleDownload = async () => { const handleDownload = async () => {
logImEXEvent("jobs_documents_download"); logImEXEvent("jobs_documents_download");
setLoading(true); setLoading(true);
const zipUrl = await axios({ try {
url: "/media/imgproxy/download", const response = await axios({
method: "POST", url: "/media/imgproxy/download",
data: { jobId, documentids: imagesToDownload.map((_) => _.id) } method: "POST",
}); responseType: "blob",
data: { jobId, documentids: imagesToDownload.map((_) => _.id) },
onDownloadProgress: downloadProgress
});
const theDownloadedZip = await cleanAxios({ setLoading(false);
url: zipUrl.data.url, setDownload(null);
method: "GET",
responseType: "arraybuffer",
onDownloadProgress: downloadProgress
});
setLoading(false);
setDownload(null);
standardMediaDownload(theDownloadedZip.data); // Use the response data (Blob) to trigger download
standardMediaDownload(response.data);
} catch (error) {
setLoading(false);
setDownload(null);
// handle error (optional)
}
}; };
return ( return (

View File

@@ -98,7 +98,13 @@ function JobsDocumentsImgproxyComponent({
jobId={jobId} jobId={jobId}
totalSize={totalSize} totalSize={totalSize}
billId={billId} billId={billId}
callbackAfterUpload={billsCallback || fetchThumbnails || refetch} callbackAfterUpload={
billsCallback ||
function () {
isFunction(refetch) && refetch();
isFunction(fetchThumbnails) && fetchThumbnails();
}
}
ignoreSizeLimit={ignoreSizeLimit} ignoreSizeLimit={ignoreSizeLimit}
/> />
</Card> </Card>

View File

@@ -1,9 +1,9 @@
import { SyncOutlined } from "@ant-design/icons"; import { SyncOutlined } from "@ant-design/icons";
import { Button, Checkbox, Divider, Input, Space, Table } from "antd"; import { Button, Checkbox, Divider, Input, Space, Table } from "antd";
import dayjs from "../../utils/day"; import { useState } from "react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import dayjs from "../../utils/day";
import PhoneFormatter from "../../utils/PhoneFormatter"; import PhoneFormatter from "../../utils/PhoneFormatter";
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component"; import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component"; import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
@@ -223,9 +223,9 @@ export default function JobsFindModalComponent({
type: "radio", type: "radio",
selectedRowKeys: [selectedJob] selectedRowKeys: [selectedJob]
}} }}
onRow={(record, rowIndex) => { onRow={(record) => {
return { return {
onClick: (event) => { onClick: () => {
handleOnRowClick(record); handleOnRowClick(record);
} }
}; };
@@ -241,15 +241,17 @@ export default function JobsFindModalComponent({
overrideHeaders: e.target.checked overrideHeaders: e.target.checked
}) })
} }
id="override_header"
> >
{t("jobs.labels.override_header")} {t("jobs.labels.override_header")}
</Checkbox> </Checkbox>
<Checkbox checked={partsQueueToggle} onChange={(e) => setPartsQueueToggle(e.target.checked)}> <Checkbox checked={partsQueueToggle} onChange={(e) => setPartsQueueToggle(e.target.checked)} id="parts_queue_toggle">
{t("bodyshop.fields.md_functionality_toggles.parts_queue_toggle")} {t("bodyshop.fields.md_functionality_toggles.parts_queue_toggle")}
</Checkbox> </Checkbox>
<Checkbox <Checkbox
checked={updateSchComp.checked} checked={updateSchComp.checked}
onChange={(e) => setSchComp({ ...updateSchComp, checked: e.target.checked })} onChange={(e) => setSchComp({ ...updateSchComp, checked: e.target.checked })}
id="update_scheduled_completion"
> >
{t("jobs.labels.update_scheduled_completion")} {t("jobs.labels.update_scheduled_completion")}
</Checkbox> </Checkbox>
@@ -261,6 +263,7 @@ export default function JobsFindModalComponent({
onChange={(e) => { onChange={(e) => {
setSchComp({ ...updateSchComp, scheduled_completion: e }); setSchComp({ ...updateSchComp, scheduled_completion: e });
}} }}
id="scheduled_completion_date_time_picker"
/> />
) : null} ) : null}
<Checkbox <Checkbox
@@ -273,6 +276,7 @@ export default function JobsFindModalComponent({
automatic: true automatic: true
}); });
}} }}
id="calculate_scheduled_completion"
> >
{t("jobs.labels.calc_scheuled_completion")} {t("jobs.labels.calc_scheuled_completion")}
</Checkbox> </Checkbox>

View File

@@ -1,6 +1,5 @@
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import { Modal } from "antd"; import { Modal } from "antd";
import React 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";
@@ -66,7 +65,7 @@ export default connect(
title={t("jobs.labels.existing_jobs")} title={t("jobs.labels.existing_jobs")}
width={"80%"} width={"80%"}
destroyOnHidden destroyOnHidden
okButtonProps={{ disabled: selectedJob ? false : true }} okButtonProps={{ disabled: selectedJob ? false : true, id: "jobs-find-modal-container-ok" }}
{...modalProps} {...modalProps}
> >
{loading ? <LoadingSpinner /> : null} {loading ? <LoadingSpinner /> : null}

View File

@@ -3,7 +3,7 @@ import { Button, Card, Input, Space, Table, Typography } from "antd";
import axios from "axios"; import axios from "axios";
import _ from "lodash"; import _ from "lodash";
import queryString from "query-string"; import queryString from "query-string";
import React, { useEffect, useState } from "react"; import { 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 { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
@@ -20,7 +20,7 @@ const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser //currentUser: selectCurrentUser
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
@@ -203,6 +203,8 @@ export function JobsList({ bodyshop, refetch, loading, jobs, total }) {
return ( return (
<Card <Card
id="all-jobs-list"
title={t("titles.bc.jobs-all")}
extra={ extra={
<Space wrap> <Space wrap>
{search.search && ( {search.search && (
@@ -256,6 +258,7 @@ export function JobsList({ bodyshop, refetch, loading, jobs, total }) {
rowKey="id" rowKey="id"
dataSource={search?.search ? openSearchResults : jobs} dataSource={search?.search ? openSearchResults : jobs}
onChange={handleTableChange} onChange={handleTableChange}
id="all-jobs-list-table"
/> />
</Card> </Card>
); );

View File

@@ -2,7 +2,7 @@ import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined, SyncOut
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import { Button, Card, Grid, Input, Space, Table, Tooltip } from "antd"; import { Button, Card, Grid, Input, Space, Table, Tooltip } from "antd";
import queryString from "query-string"; import queryString from "query-string";
import React, { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
@@ -22,7 +22,7 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({}); const mapDispatchToProps = () => ({});
export function JobsList({ bodyshop }) { export function JobsList({ bodyshop }) {
const searchParams = queryString.parse(useLocation().search); const searchParams = queryString.parse(useLocation().search);
@@ -342,13 +342,14 @@ export function JobsList({ bodyshop }) {
type: "radio" type: "radio"
}} }}
onChange={handleTableChange} onChange={handleTableChange}
onRow={(record, rowIndex) => { onRow={(record) => {
return { return {
onClick: (event) => { onClick: () => {
handleOnRowClick(record); handleOnRowClick(record);
} }
}; };
}} }}
id="active-jobs-list-table"
/> />
</Card> </Card>
); );

View File

@@ -131,4 +131,6 @@ const NotificationCenterComponent = forwardRef(
} }
); );
NotificationCenterComponent.displayName = "NotificationCenterComponent";
export default NotificationCenterComponent; export default NotificationCenterComponent;

View File

@@ -1,14 +1,15 @@
import { Form, Input, Switch } from "antd"; import { Form, Input, Tooltip } from "antd";
import React from "react"; import { CloseCircleFilled } from "@ant-design/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component"; import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import FormItemEmail from "../form-items-formatted/email-form-item.component"; import FormItemEmail from "../form-items-formatted/email-form-item.component";
import FormItemPhone, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
export default function OwnerDetailFormComponent({ form, loading }) { export default function OwnerDetailFormComponent({ form, loading, isPhone1OptedOut, isPhone2OptedOut }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { getFieldValue } = form; const { getFieldValue } = form;
return ( return (
<div> <div>
<FormFieldsChanged form={form} /> <FormFieldsChanged form={form} />
@@ -26,7 +27,7 @@ export default function OwnerDetailFormComponent({ form, loading }) {
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item label={t("owners.fields.accountingid")} name="accountingid"> <Form.Item label={t("owners.fields.accountingid")} name="accountingid">
<Input disabled/> <Input disabled />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow header={t("owners.forms.address")}> <LayoutFormRow header={t("owners.forms.address")}>
@@ -50,9 +51,6 @@ export default function OwnerDetailFormComponent({ form, loading }) {
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow header={t("owners.forms.contact")}> <LayoutFormRow header={t("owners.forms.contact")}>
<Form.Item label={t("owners.fields.allow_text_message")} name="allow_text_message" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item <Form.Item
label={t("owners.fields.ownr_ea")} label={t("owners.fields.ownr_ea")}
name="ownr_ea" name="ownr_ea"
@@ -65,19 +63,55 @@ export default function OwnerDetailFormComponent({ form, loading }) {
> >
<FormItemEmail email={getFieldValue("ownr_ea")} /> <FormItemEmail email={getFieldValue("ownr_ea")} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item label={t("owners.fields.ownr_ph1")} style={{ marginBottom: 0 }}>
label={t("owners.fields.ownr_ph1")} <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
name="ownr_ph1" <Form.Item
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph1")]} name="ownr_ph1"
> noStyle
<FormItemPhone /> rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph1")]}
>
<Input style={{ flex: 1, minWidth: "150px" }} />
</Form.Item>
{isPhone1OptedOut && (
<Tooltip title={t("consent.text_body")}>
<CloseCircleFilled
style={{
color: "#ff4d4f",
fontSize: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%"
}}
/>
</Tooltip>
)}
</div>
</Form.Item> </Form.Item>
<Form.Item <Form.Item label={t("owners.fields.ownr_ph2")} style={{ marginBottom: 0 }}>
label={t("owners.fields.ownr_ph2")} <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
name="ownr_ph2" <Form.Item
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph2")]} name="ownr_ph2"
> noStyle
<FormItemPhone /> rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph2")]}
>
<Input style={{ flex: 1, minWidth: "150px" }} />
</Form.Item>
{isPhone2OptedOut && (
<Tooltip title={t("consent.text_body")}>
<CloseCircleFilled
style={{
color: "#ff4d4f",
fontSize: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%"
}}
/>
</Tooltip>
)}
</div>
</Form.Item> </Form.Item>
<Form.Item label={t("owners.fields.preferred_contact")} name="preferred_contact"> <Form.Item label={t("owners.fields.preferred_contact")} name="preferred_contact">
<Input /> <Input />

View File

@@ -1,69 +1,115 @@
import { Button, Form, Popconfirm } from "antd"; import { Button, Form, Popconfirm } from "antd";
import { PageHeader } from "@ant-design/pro-layout"; import { PageHeader } from "@ant-design/pro-layout";
import { useEffect, useState } from "react";
import React, { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useMutation } from "@apollo/client"; import { useApolloClient, useMutation } from "@apollo/client";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { DELETE_OWNER, UPDATE_OWNER } from "../../graphql/owners.queries"; import { DELETE_OWNER, UPDATE_OWNER } from "../../graphql/owners.queries";
import { selectBodyshop } from "../../redux/user/user.selectors"; // Adjust path
import { phoneNumberOptOutService } from "../../utils/phoneOptOutService.js"; // Adjust path
import OwnerDetailFormComponent from "./owner-detail-form.component"; import OwnerDetailFormComponent from "./owner-detail-form.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { phone } from "phone"; // Import phone utility for formatting
function OwnerDetailFormContainer({ owner, refetch }) { // Connect to Redux to access bodyshop
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
function OwnerDetailFormContainer({ owner, refetch, bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const history = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [optedOutPhones, setOptedOutPhones] = useState(new Set());
const [updateOwner] = useMutation(UPDATE_OWNER); const [updateOwner] = useMutation(UPDATE_OWNER);
const [deleteOwner] = useMutation(DELETE_OWNER); const [deleteOwner] = useMutation(DELETE_OWNER);
const notification = useNotification(); const notification = useNotification();
const apolloClient = useApolloClient();
// Fetch opt-out status on mount
useEffect(() => {
const fetchOptOutStatus = async () => {
if (bodyshop?.id && bodyshop?.messagingservicesid && (owner?.ownr_ph1 || owner?.ownr_ph2)) {
const phoneNumbers = [owner.ownr_ph1, owner.ownr_ph2].filter(Boolean);
const optOutSet = await phoneNumberOptOutService(apolloClient, bodyshop.id, phoneNumbers);
setOptedOutPhones(optOutSet);
} else {
setOptedOutPhones(new Set());
}
};
fetchOptOutStatus();
}, [apolloClient, bodyshop?.id, bodyshop?.messagingservicesid, owner?.ownr_ph1, owner?.ownr_ph2]);
// Reset form fields when owner changes
useEffect(() => {
form.setFieldsValue({
ownr_ph1: owner?.ownr_ph1,
ownr_ph2: owner?.ownr_ph2,
...owner
});
}, [owner, form]);
const handleDelete = async () => { const handleDelete = async () => {
setLoading(true); setLoading(true);
const result = await deleteOwner({ try {
variables: { id: owner.id } const result = await deleteOwner({
}); variables: { id: owner.id }
console.log(result); });
if (result.errors) { if (result.errors) {
notification["error"]({ notification.error({
message: t("owners.errors.deleting", {
error: JSON.stringify(result.errors)
})
});
} else {
notification.success({
message: t("owners.successes.delete")
});
navigate(`/manage/owners`);
}
} catch (error) {
notification.error({
message: t("owners.errors.deleting", { message: t("owners.errors.deleting", {
error: JSON.stringify(result.errors) error: error.message
}) })
}); });
} finally {
setLoading(false); setLoading(false);
} else {
notification["success"]({
message: t("owners.successes.delete")
});
setLoading(false);
history(`/manage/owners`);
} }
}; };
const handleFinish = async (values) => { const handleFinish = async (values) => {
setLoading(true); setLoading(true);
const result = await updateOwner({ try {
variables: { ownerId: owner.id, owner: values } const result = await updateOwner({
}); variables: { ownerId: owner.id, owner: values }
});
if (!!result.errors) { if (result.errors) {
notification["error"]({ notification.error({
message: t("owners.errors.saving", {
error: JSON.stringify(result.errors)
})
});
} else {
notification.success({
message: t("owners.successes.save")
});
if (refetch) await refetch();
form.resetFields();
}
} catch (error) {
notification.error({
message: t("owners.errors.saving", { message: t("owners.errors.saving", {
error: JSON.stringify(result.errors) error: error.message
}) })
}); });
} finally {
setLoading(false); setLoading(false);
return;
} }
notification["success"]({
message: t("owners.successes.save")
});
if (refetch) await refetch();
form.resetFields();
form.resetFields();
setLoading(false);
}; };
return ( return (
@@ -72,6 +118,7 @@ function OwnerDetailFormContainer({ owner, refetch }) {
title={t("menus.header.owners")} title={t("menus.header.owners")}
extra={[ extra={[
<Popconfirm <Popconfirm
key="delete"
trigger="click" trigger="click"
onConfirm={handleDelete} onConfirm={handleDelete}
disabled={owner.jobs.length !== 0} disabled={owner.jobs.length !== 0}
@@ -81,16 +128,29 @@ function OwnerDetailFormContainer({ owner, refetch }) {
{t("general.actions.delete")} {t("general.actions.delete")}
</Button> </Button>
</Popconfirm>, </Popconfirm>,
<Button type="primary" loading={loading} onClick={() => form.submit()}> <Button key="save" type="primary" loading={loading} onClick={() => form.submit()}>
{t("general.actions.save")} {t("general.actions.save")}
</Button> </Button>
]} ]}
/> />
<Form form={form} onFinish={handleFinish} autoComplete="off" layout="vertical" initialValues={owner}> <Form form={form} onFinish={handleFinish} autoComplete="off" layout="vertical" initialValues={owner}>
<OwnerDetailFormComponent loading={loading} form={form} /> <OwnerDetailFormComponent
loading={loading}
form={form}
isPhone1OptedOut={
bodyshop?.messagingservicesid &&
owner?.ownr_ph1 &&
optedOutPhones.has(phone(owner.ownr_ph1, "CA").phoneNumber?.replace(/^\+1/, ""))
}
isPhone2OptedOut={
bodyshop?.messagingservicesid &&
owner?.ownr_ph2 &&
optedOutPhones.has(phone(owner.ownr_ph2, "CA").phoneNumber?.replace(/^\+1/, ""))
}
/>
</Form> </Form>
</> </>
); );
} }
export default OwnerDetailFormContainer; export default connect(mapStateToProps)(OwnerDetailFormContainer);

View File

@@ -1,12 +1,12 @@
import { useLazyQuery } from "@apollo/client"; import { useLazyQuery } from "@apollo/client";
import { Input, Modal } from "antd"; import { Input, Modal } from "antd";
import React, { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { QUERY_SEARCH_OWNER_BY_IDX } from "../../graphql/owners.queries"; import { QUERY_SEARCH_OWNER_BY_IDX } from "../../graphql/owners.queries";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component"; import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import OwnerFindModalComponent from "./owner-find-modal.component";
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component"; import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import OwnerFindModalComponent from "./owner-find-modal.component";
export default function OwnerFindModalContainer({ export default function OwnerFindModalContainer({
loading, loading,
@@ -41,6 +41,7 @@ export default function OwnerFindModalContainer({
<Modal <Modal
title={<span id="owner-find-modal-title">{t("owners.labels.existing_owners")}</span>} title={<span id="owner-find-modal-title">{t("owners.labels.existing_owners")}</span>}
width={"80%"} width={"80%"}
okButtonProps={{ id: "owner-find-modal-ok-button" }}
{...modalProps} {...modalProps}
> >
{loading ? <LoadingSpinner /> : null} {loading ? <LoadingSpinner /> : null}

View File

@@ -75,7 +75,7 @@ export function PartsOrderBackorderEta({
); );
return ( return (
<Popover destroyTooltipOnHide content={popContent} open={visibility} disabled={disabled}> <Popover destroyOnHidden content={popContent} open={visibility} disabled={disabled}>
<DateFormatter>{backordered_eta}</DateFormatter> <DateFormatter>{backordered_eta}</DateFormatter>
{isAlreadyBackordered && <CalendarFilled style={{ cursor: "pointer" }} onClick={handlePopover} />} {isAlreadyBackordered && <CalendarFilled style={{ cursor: "pointer" }} onClick={handlePopover} />}
{loading && <Spin />} {loading && <Spin />}

View File

@@ -84,7 +84,7 @@ export function PartsOrderLineBackorderButton({ partsOrderStatus, partsLineId, j
); );
return ( return (
<Popover destroyTooltipOnHide content={popContent} open={visibility} disabled={disabled}> <Popover destroyOnHidden content={popContent} open={visibility} disabled={disabled}>
<Button loading={loading} onClick={handlePopover}> <Button loading={loading} onClick={handlePopover}>
{isAlreadyBackordered ? t("parts_orders.actions.receive") : t("parts_orders.actions.backordered")} {isAlreadyBackordered ? t("parts_orders.actions.receive") : t("parts_orders.actions.backordered")}
</Button> </Button>

View File

@@ -0,0 +1,146 @@
import { useQuery } from "@apollo/client";
import { Input, Table, Typography } from "antd";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries";
import { TimeAgoFormatter } from "../../utils/DateFormatter";
import ChatOpenButton from "../chat-open-button/chat-open-button.component";
import { useTranslation } from "react-i18next";
import { useState } from "react";
const { Paragraph } = Typography;
// Commented out Associated Owners section for now
//import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
//import { Link } from "react-router-dom";
//import { useMemo, useState } from "react";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
});
const mapDispatchToProps = () => ({});
function PhoneNumberConsentList({ bodyshop, currentUser }) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
// Fetch opt-out phone numbers
const { loading: optOutLoading, data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUTS, {
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined },
fetchPolicy: "network-only"
});
// Commented out Associated Owners section for now
/*// Prepare phone numbers for owner query
const phoneNumbers = useMemo(() => {
return optOutData?.phone_number_opt_out?.map((item) => item.phone_number) || [];
}, [optOutData?.phone_number_opt_out]);
const allPhoneNumbers = useMemo(() => {
const normalized = phoneNumbers;
const withPlusOne = phoneNumbers.map((num) => `+1${num}`);
return [...normalized, ...withPlusOne].filter(Boolean);
}, [phoneNumbers]);
// Fetch owners for all phone numbers
const { loading: ownersLoading, data: ownersData } = useQuery(SEARCH_OWNERS_BY_PHONE_NUMBERS, {
variables: { bodyshopid: bodyshop.id, phone_numbers: allPhoneNumbers },
skip: allPhoneNumbers.length === 0 || !bodyshop.id,
fetchPolicy: "network-only"
});
// Map phone numbers to their associated owners and identify phone field
const getAssociatedOwners = (phoneNumber) => {
if (!ownersData?.owners) return [];
const normalizedPhone = phoneNumber.replace(/^\+1/, "");
return ownersData.owners
.filter(
(owner) =>
owner.ownr_ph1 === phoneNumber ||
owner.ownr_ph2 === phoneNumber ||
owner.ownr_ph1 === normalizedPhone ||
owner.ownr_ph2 === normalizedPhone ||
owner.ownr_ph1 === `+1${phoneNumber}` ||
owner.ownr_ph2 === `+1${phoneNumber}`
)
.map((owner) => ({
...owner,
phoneField:
[owner.ownr_ph1, owner.ownr_ph2].includes(phoneNumber) ||
[owner.ownr_ph1, owner.ownr_ph2].includes(normalizedPhone) ||
[owner.ownr_ph1, owner.ownr_ph2].includes(`+1${phoneNumber}`)
? owner.ownr_ph1 === phoneNumber ||
owner.ownr_ph1 === normalizedPhone ||
owner.ownr_ph1 === `+1${phoneNumber}`
? t("consent.phone_1")
: t("consent.phone_2")
: null
}));
};*/
const columns = [
{
title: t("consent.phone_number"),
dataIndex: "phone_number",
render: (text) => <ChatOpenButton phone={text} />,
sorter: (a, b) => a.phone_number.localeCompare(b.phone_number)
},
// Commented out Associated Owners section for now
/*{
title: t("consent.associated_owners"),
dataIndex: "phone_number",
render: (phoneNumber) => {
const owners = getAssociatedOwners(phoneNumber);
if (!owners || owners.length === 0) {
return t("consent.no_owners");
}
return owners.map((owner) => (
<div key={owner.id}>
<Space direction="horizontal">
<Link to={"/manage/owners/" + owner.id}>
<OwnerNameDisplay ownerObject={owner} />
</Link>
({owner.phoneField})
</Space>
</div>
));
},
sorter: (a, b) => {
const aOwners = getAssociatedOwners(a.phone_number);
const bOwners = getAssociatedOwners(b.phone_number);
const aName = aOwners[0] ? `${aOwners[0].ownr_fn} ${aOwners[0].ownr_ln}` : "";
const bName = bOwners[0] ? `${bOwners[0].ownr_fn} ${bOwners[0].ownr_ln}` : "";
return aName.localeCompare(bName);
}
},*/
{
title: t("consent.created_at"),
dataIndex: "created_at",
render: (text) => <TimeAgoFormatter>{text}</TimeAgoFormatter>,
sorter: (a, b) => new Date(a.created_at) - new Date(b.created_at)
}
];
return (
<div>
<Paragraph>{t("consent.text_body")}</Paragraph>
<Input.Search
placeholder={t("general.labels.search")}
onSearch={(value) => setSearch(value)}
style={{ marginBottom: 16 }}
/>
<Table
columns={columns}
dataSource={optOutData?.phone_number_opt_out}
loading={optOutLoading /* || ownersLoading*/}
rowKey="id"
style={{ marginTop: 16 }}
/>
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(PhoneNumberConsentList);

View File

@@ -1,7 +1,15 @@
import { Card, Col, Form, Radio, Row } from "antd"; import { Card, Col, Form, Radio, Row } from "antd";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../../redux/user/user.selectors";
import { HasFeatureAccess } from "../../feature-wrapper/feature-wrapper.component";
const LayoutSettings = ({ t }) => ( const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const LayoutSettings = ({ t, bodyshop }) => (
<Card title={t("production.settings.layout")} style={{ maxWidth: "100%", overflowX: "auto" }}> <Card title={t("production.settings.layout")} style={{ maxWidth: "100%", overflowX: "auto" }}>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{[ {[
@@ -30,14 +38,18 @@ const LayoutSettings = ({ t }) => (
{ value: false, label: t("production.labels.wide") } { value: false, label: t("production.labels.wide") }
] ]
}, },
{ ...(HasFeatureAccess({ bodyshop, featureName: "smartscheduling" })
name: "cardcolor", ? [
label: t("production.labels.cardcolor"), {
options: [ name: "cardcolor",
{ value: true, label: t("production.labels.on") }, label: t("production.labels.cardcolor"),
{ value: false, label: t("production.labels.off") } options: [
] { value: true, label: t("production.labels.on") },
}, { value: false, label: t("production.labels.off") }
]
}
]
: []),
{ {
name: "kiosk", name: "kiosk",
label: t("production.labels.kiosk_mode"), label: t("production.labels.kiosk_mode"),
@@ -67,4 +79,4 @@ LayoutSettings.propTypes = {
t: PropTypes.func.isRequired t: PropTypes.func.isRequired
}; };
export default LayoutSettings; export default connect(mapStateToProps)(LayoutSettings);

View File

@@ -20,7 +20,7 @@ const Board = ({ id, className, orientation, cardSettings, ...additionalProps })
default: default:
return cardSizesVertical.small; return cardSizesVertical.small;
} }
}, [cardSettings]); }, [cardSettings?.cardSize]);
return ( return (
<> <>

View File

@@ -101,11 +101,33 @@ const BoardContainer = ({
async ({ draggableId, type, source, reason, mode, destination, combine }) => { async ({ draggableId, type, source, reason, mode, destination, combine }) => {
setIsDragging(false); setIsDragging(false);
// Only update drag time if it's a valid drop with a different destination // Validate drag type and source
if (type === "lane" && source && destination && !isEqual(source, destination)) { if (type !== "lane" || !source) {
setDragTime(source.droppableId); // Invalid drag type or missing source, attempt to revert if possible
setIsProcessing(true); if (source) {
dispatch(
actions.moveCardAcrossLanes({
fromLaneId: source.droppableId,
toLaneId: source.droppableId,
cardId: draggableId,
index: source.index
})
);
}
setIsProcessing(false);
try {
await onDragEnd({ draggableId, type, source, reason, mode, destination, combine });
} catch (err) {
console.error("Error in onLaneDrag for invalid drag type or source", err);
}
return;
}
setDragTime(source.droppableId);
setIsProcessing(true);
// Handle valid drop to a different lane or position
if (destination && !isEqual(source, destination)) {
dispatch( dispatch(
actions.moveCardAcrossLanes({ actions.moveCardAcrossLanes({
fromLaneId: source.droppableId, fromLaneId: source.droppableId,
@@ -114,14 +136,33 @@ const BoardContainer = ({
index: destination.index index: destination.index
}) })
); );
} else {
// Same-lane drop or no destination, revert to original position
dispatch(
actions.moveCardAcrossLanes({
fromLaneId: source.droppableId,
toLaneId: source.droppableId,
cardId: draggableId,
index: source.index
})
);
}
try { try {
await onDragEnd({ draggableId, type, source, reason, mode, destination, combine }); await onDragEnd({ draggableId, type, source, reason, mode, destination, combine });
} catch (err) { } catch (err) {
console.error("Error in onLaneDrag", err); console.error("Error in onLaneDrag", err);
} finally { // Ensure revert on error
setIsProcessing(false); dispatch(
} actions.moveCardAcrossLanes({
fromLaneId: source.droppableId,
toLaneId: source.droppableId,
cardId: draggableId,
index: source.index
})
);
} finally {
setIsProcessing(false);
} }
}, },
[dispatch, onDragEnd, setDragTime] [dispatch, onDragEnd, setDragTime]

View File

@@ -133,7 +133,9 @@ const Lane = ({
Item: ItemComponent Item: ItemComponent
}, },
itemContent: (index, item) => <ItemWrapper>{renderDraggable(index, item)}</ItemWrapper>, itemContent: (index, item) => <ItemWrapper>{renderDraggable(index, item)}</ItemWrapper>,
overscan: { main: 10, reverse: 10 } overscan: { main: 10, reverse: 10 },
// Ensure a minimum height for empty lanes to allow dropping
style: renderedCards.length === 0 ? { minHeight: "5px" } : {}
}; };
const horizontalProps = { const horizontalProps = {
@@ -149,8 +151,6 @@ const Lane = ({
const componentProps = orientation === "vertical" ? verticalProps : horizontalProps; const componentProps = orientation === "vertical" ? verticalProps : horizontalProps;
// If the lane is collapsed, we want to render a div instead of the virtualized list, and we want to set the height to the max height of the lane so that
// the lane doesn't shrink when collapsed (in horizontal mode)
const finalComponentProps = collapsed const finalComponentProps = collapsed
? orientation === "horizontal" ? orientation === "horizontal"
? { ? {
@@ -161,9 +161,8 @@ const Lane = ({
: {} : {}
: componentProps; : componentProps;
// If the lane is horizontal and collapsed, we want to render a placeholder so that the lane doesn't shrink to 0 height and grows when // Always render placeholder for empty lanes in vertical mode to ensure droppable area
// a card is dragged over it const shouldRenderPlaceholder = orientation === "vertical" ? collapsed || renderedCards.length === 0 : collapsed;
const shouldRenderPlaceholder = orientation !== "horizontal" && (collapsed || renderedCards.length === 0);
return ( return (
<HeightMemoryWrapper <HeightMemoryWrapper
@@ -178,8 +177,8 @@ const Lane = ({
override={orientation !== "horizontal" && (collapsed || !renderedCards.length)} override={orientation !== "horizontal" && (collapsed || !renderedCards.length)}
> >
<div <div
ref={laneRef} // Ensure laneRef is set here ref={laneRef}
style={{ height: "100%", width: "100%" }} // Make it scrollable style={{ height: "100%", width: "100%" }}
className={`react-trello-lane ${collapsed ? "lane-collapsed" : ""}`} className={`react-trello-lane ${collapsed ? "lane-collapsed" : ""}`}
> >
<div {...provided.droppableProps} ref={provided.innerRef} style={{ ...provided.droppableProps.style }}> <div {...provided.droppableProps} ref={provided.innerRef} style={{ ...provided.droppableProps.style }}>

View File

@@ -140,7 +140,7 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
if (record[type]) theEmployee = bodyshop.employees.find((e) => e.id === record[type]); if (record[type]) theEmployee = bodyshop.employees.find((e) => e.id === record[type]);
return ( return (
<Popover destroyTooltipOnHide content={popContent} open={visibility}> <Popover destroyOnHidden content={popContent} open={visibility}>
<Spin spinning={loading}> <Spin spinning={loading}>
{record[type] ? ( {record[type] ? (
<div> <div>

View File

@@ -6,6 +6,7 @@ import React, { 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 { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { QUERY_ACTIVE_EMPLOYEES, QUERY_ACTIVE_EMPLOYEES_WITH_EMAIL } from "../../graphql/employees.queries"; import { QUERY_ACTIVE_EMPLOYEES, QUERY_ACTIVE_EMPLOYEES_WITH_EMAIL } from "../../graphql/employees.queries";
import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries"; import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries";
import { selectReportCenter } from "../../redux/modals/modals.selectors"; import { selectReportCenter } from "../../redux/modals/modals.selectors";
@@ -18,11 +19,10 @@ import EmployeeSearchSelectEmail from "../employee-search-select/employee-search
import EmployeeSearchSelect from "../employee-search-select/employee-search-select.component"; import EmployeeSearchSelect from "../employee-search-select/employee-search-select.component";
import BlurWrapperComponent from "../feature-wrapper/blur-wrapper.component"; import BlurWrapperComponent from "../feature-wrapper/blur-wrapper.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component"; import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
import ReportCenterModalFiltersSortersComponent from "./report-center-modal-filters-sorters-component"; import ReportCenterModalFiltersSortersComponent from "./report-center-modal-filters-sorters-component";
import "./report-center-modal.styles.scss"; import "./report-center-modal.styles.scss";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
reportCenterModal: selectReportCenter, reportCenterModal: selectReportCenter,
@@ -389,5 +389,7 @@ const restrictedReports = [
{ key: "job_costing_ro_date_detail", days: 183 }, { key: "job_costing_ro_date_detail", days: 183 },
{ key: "job_costing_ro_estimator", days: 183 }, { key: "job_costing_ro_estimator", days: 183 },
{ key: "job_lifecycle_date_detail", days: 183 }, { key: "job_lifecycle_date_detail", days: 183 },
{ key: "job_lifecycle_date_summary", days: 183 } { key: "job_lifecycle_date_summary", days: 183 },
{ key: "customer_list", days: 183 },
{ key: "customer_list_excel", days: 183 }
]; ];

View File

@@ -1,9 +1,9 @@
import Icon from "@ant-design/icons"; import Icon from "@ant-design/icons";
import { Card, Popover, Space } from "antd"; import { Card, Popover, Space } from "antd";
import _ from "lodash"; import { groupBy } from "lodash";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import React, { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { MdFileDownload, MdFileUpload } from "react-icons/md"; import { MdFileDownload, MdFileUpload } from "react-icons/md";
import { connect } from "react-redux"; import { connect } from "react-redux";
@@ -26,21 +26,12 @@ const mapStateToProps = createStructuredSelector({
calculating: selectScheduleLoadCalculating calculating: selectScheduleLoadCalculating
}); });
const mapDispatchToProps = (dispatch) => ({}); const mapDispatchToProps = () => ({});
export function ScheduleCalendarHeaderComponent({ export function ScheduleCalendarHeaderComponent({ bodyshop, label, refetch, date, load, calculating, events }) {
bodyshop,
label,
refetch,
date,
load,
calculating,
events,
...otherProps
}) {
const ATSToday = useMemo(() => { const ATSToday = useMemo(() => {
if (!events) return []; if (!events) return [];
return _.groupBy( return groupBy(
events.filter((e) => !e.vacation && e.isintake && dayjs(date).isSame(dayjs(e.start), "day")), events.filter((e) => !e.vacation && e.isintake && dayjs(date).isSame(dayjs(e.start), "day")),
"job.alt_transport" "job.alt_transport"
); );
@@ -155,7 +146,11 @@ export function ScheduleCalendarHeaderComponent({
<Space size="small"> <Space size="small">
<Icon component={MdFileDownload} style={{ color: "green" }} /> <Icon component={MdFileDownload} style={{ color: "green" }} />
<BlurWrapper featureName="smartscheduling"> <BlurWrapper featureName="smartscheduling">
<span>{`${(loadData.allHoursInBody || 0) && loadData.allHoursInBody.toFixed(1)}/${(loadData.allHoursInRefinish || 0) && loadData.allHoursInRefinish.toFixed(1)}/${(loadData.allHoursIn || 0) && loadData.allHoursIn.toFixed(1)}`}</span> <span>
{`${(loadData.allHoursInBody || 0) && loadData.allHoursInBody.toFixed(1)}/${
(loadData.allHoursInRefinish || 0) && loadData.allHoursInRefinish.toFixed(1)
}/${(loadData.allHoursIn || 0) && loadData.allHoursIn.toFixed(1)}`}
</span>
</BlurWrapper> </BlurWrapper>
</Space> </Space>
</Popover> </Popover>

View File

@@ -1,10 +1,10 @@
import { useMutation, useQuery } from "@apollo/client"; import { useMutation, useQuery } from "@apollo/client";
import { Form, Modal } from "antd"; import { Form, Modal } from "antd";
import dayjs from "../../utils/day"; import { useEffect, useState } from "react";
import React, { 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 { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
import { import {
CANCEL_APPOINTMENT_BY_ID, CANCEL_APPOINTMENT_BY_ID,
@@ -19,9 +19,9 @@ import { selectSchedule } from "../../redux/modals/modals.selectors";
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 { DateTimeFormat } from "../../utils/DateFormatter"; import { DateTimeFormat } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import { TemplateList } from "../../utils/TemplateConstants"; import { TemplateList } from "../../utils/TemplateConstants";
import ScheduleJobModalComponent from "./schedule-job-modal.component"; import ScheduleJobModalComponent from "./schedule-job-modal.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -72,7 +72,7 @@ export function ScheduleJobModalContainer({
variables: { jobid: jobId }, variables: { jobid: jobId },
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only", nextFetchPolicy: "network-only",
skip: !open || !!!jobId skip: !open || !jobId
}); });
useEffect(() => { useEffect(() => {
@@ -93,12 +93,12 @@ export function ScheduleJobModalContainer({
logImEXEvent("schedule_new_appointment"); logImEXEvent("schedule_new_appointment");
setLoading(true); setLoading(true);
if (!!previousEvent) { if (previousEvent) {
const cancelAppt = await cancelAppointment({ const cancelAppt = await cancelAppointment({
variables: { appid: previousEvent } variables: { appid: previousEvent }
}); });
if (!!cancelAppt.errors) { if (cancelAppt.errors) {
notification["error"]({ notification["error"]({
message: t("appointments.errors.canceling", { message: t("appointments.errors.canceling", {
message: JSON.stringify(cancelAppt.errors) message: JSON.stringify(cancelAppt.errors)
@@ -146,7 +146,7 @@ export function ScheduleJobModalContainer({
}); });
} }
if (!!appt.errors) { if (appt.errors) {
notification["error"]({ notification["error"]({
message: t("appointments.errors.saving", { message: t("appointments.errors.saving", {
message: JSON.stringify(appt.errors) message: JSON.stringify(appt.errors)
@@ -172,7 +172,7 @@ export function ScheduleJobModalContainer({
} }
}); });
if (!!jobUpdate.errors) { if (jobUpdate.errors) {
notification["error"]({ notification["error"]({
message: t("appointments.errors.saving", { message: t("appointments.errors.saving", {
message: JSON.stringify(jobUpdate.errors) message: JSON.stringify(jobUpdate.errors)
@@ -222,9 +222,9 @@ export function ScheduleJobModalContainer({
initialValues={{ initialValues={{
notifyCustomer: !!(job && job.ownr_ea), notifyCustomer: !!(job && job.ownr_ea),
email: (job && job.ownr_ea) || "", email: (job && job.ownr_ea) || "",
start: null,
// smartDates: [], // smartDates: [],
scheduled_completion: null, start: context.scheduled_in,
scheduled_completion: context.scheduled_completion ,
color: context.color, color: context.color,
alt_transport: context.alt_transport, alt_transport: context.alt_transport,
note: context.note note: context.note

View File

@@ -1,8 +1,7 @@
import { Card } from "antd"; import { Card } from "antd";
import Dinero from "dinero.js"; import Dinero from "dinero.js";
import _ from "lodash"; import { round } from "lodash";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { import {
Area, Area,
@@ -29,7 +28,7 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export default connect(mapStateToProps, mapDispatchToProps)(ScoreboardChart); export default connect(mapStateToProps, mapDispatchToProps)(ScoreboardChart);
@@ -40,7 +39,7 @@ export function ScoreboardChart({ sbEntriesByDate, bodyshop }) {
const data = listOfBusDays.reduce((acc, val) => { const data = listOfBusDays.reduce((acc, val) => {
//Sum up the current day. //Sum up the current day.
let dayhrs; let dayhrs;
if (!!sbEntriesByDate[val]) { if (sbEntriesByDate[val]) {
dayhrs = sbEntriesByDate[val].reduce( dayhrs = sbEntriesByDate[val].reduce(
(dayAcc, dayVal) => { (dayAcc, dayVal) => {
return { return {
@@ -61,9 +60,9 @@ export function ScoreboardChart({ sbEntriesByDate, bodyshop }) {
const theValue = { const theValue = {
date: dayjs(val).format("D ddd"), date: dayjs(val).format("D ddd"),
paintHrs: _.round(dayhrs.painthrs, 1), paintHrs: round(dayhrs.painthrs, 1),
bodyHrs: _.round(dayhrs.bodyhrs, 1), bodyHrs: round(dayhrs.bodyhrs, 1),
accTargetHrs: _.round( accTargetHrs: round(
Utils.AsOfDateTargetHours( Utils.AsOfDateTargetHours(
bodyshop.scoreboard_target.dailyBodyTarget + bodyshop.scoreboard_target.dailyPaintTarget, bodyshop.scoreboard_target.dailyBodyTarget + bodyshop.scoreboard_target.dailyPaintTarget,
val val
@@ -72,14 +71,14 @@ export function ScoreboardChart({ sbEntriesByDate, bodyshop }) {
bodyshop.scoreboard_target.dailyPaintTarget, bodyshop.scoreboard_target.dailyPaintTarget,
1 1
), ),
accHrs: _.round( accHrs: round(
acc.length > 0 acc.length > 0
? acc[acc.length - 1].accHrs + dayhrs.painthrs + dayhrs.bodyhrs ? acc[acc.length - 1].accHrs + dayhrs.painthrs + dayhrs.bodyhrs
: dayhrs.painthrs + dayhrs.bodyhrs, : dayhrs.painthrs + dayhrs.bodyhrs,
1 1
), ),
sales: _.round(dayhrs.sales, 2), sales: round(dayhrs.sales, 2),
accSales: _.round(acc.length > 0 ? acc[acc.length - 1].accSales + dayhrs.sales : dayhrs.sales, 2) accSales: round(acc.length > 0 ? acc[acc.length - 1].accSales + dayhrs.sales : dayhrs.sales, 2)
}; };
return [...acc, theValue]; return [...acc, theValue];

View File

@@ -1,23 +1,25 @@
import { Col, Row } from "antd"; import { Col, Row, Spin } from "antd";
import React, { useEffect } from "react"; import { useEffect, useState } from "react";
import ScoreboardChart from "../scoreboard-chart/scoreboard-chart.component"; import ScoreboardChart from "../scoreboard-chart/scoreboard-chart.component";
import ScoreboardLastDays from "../scoreboard-last-days/scoreboard-last-days.component"; import ScoreboardLastDays from "../scoreboard-last-days/scoreboard-last-days.component";
import ScoreboardTargetsTable from "../scoreboard-targets-table/scoreboard-targets-table.component"; import ScoreboardTargetsTable from "../scoreboard-targets-table/scoreboard-targets-table.component";
import { useApolloClient, useQuery } from "@apollo/client"; import { useApolloClient, useQuery } from "@apollo/client";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { GET_BLOCKED_DAYS, QUERY_SCOREBOARD } from "../../graphql/scoreboard.queries"; import { GET_BLOCKED_DAYS, QUERY_SCOREBOARD } from "../../graphql/scoreboard.queries";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import {
clearHolidays,
clearWorkingWeekdays,
setHolidays,
setWorkingWeekdays
} from "../scoreboard-targets-table/scoreboard-targets-table.util";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = () => ({});
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(ScoreboardDisplayComponent); export default connect(mapStateToProps, mapDispatchToProps)(ScoreboardDisplayComponent);
export function ScoreboardDisplayComponent({ bodyshop }) { export function ScoreboardDisplayComponent({ bodyshop }) {
@@ -26,63 +28,76 @@ export function ScoreboardDisplayComponent({ bodyshop }) {
start: dayjs().startOf("month"), start: dayjs().startOf("month"),
end: dayjs().endOf("month") end: dayjs().endOf("month")
}, },
pollInterval: 60000*5 pollInterval: 60000 * 5
}); });
const { data } = scoreboardSubscription; const { data } = scoreboardSubscription;
const client = useApolloClient(); const client = useApolloClient();
const scoreBoardlist = (data && data.scoreboard) || []; const scoreBoardlist = data?.scoreboard || [];
const sbEntriesByDate = {}; const sbEntriesByDate = {};
scoreBoardlist.forEach((i) => { scoreBoardlist.forEach((i) => {
const entryDate = i.date; const entryDate = i.date;
if (!!!sbEntriesByDate[entryDate]) { if (!sbEntriesByDate[entryDate]) {
sbEntriesByDate[entryDate] = []; sbEntriesByDate[entryDate] = [];
} }
sbEntriesByDate[entryDate].push(i); sbEntriesByDate[entryDate].push(i);
}); });
const [loading, setLoading] = useState(true); // Loading state
useEffect(() => { useEffect(() => {
//Update the locals.
async function setDayJSSettings() { async function setDayJSSettings() {
let appointments; try {
let appointments;
if (!bodyshop.scoreboard_target.ignoreblockeddays) { if (!bodyshop.scoreboard_target.ignoreblockeddays) {
const { data } = await client.query({ const { data } = await client.query({
query: GET_BLOCKED_DAYS, query: GET_BLOCKED_DAYS,
variables: { variables: {
start: dayjs().startOf("month"), start: dayjs().startOf("month"),
end: dayjs().endOf("month") end: dayjs().endOf("month")
}
});
appointments = data.appointments;
}
dayjs.updateLocale("ca", {
workingWeekdays: translateSettingsToWorkingDays(bodyshop.workingdays),
...(appointments
? {
holidays: appointments.map((h) => dayjs(h.start).format("MM-DD-YYYY"))
} }
: {}), });
holidayFormat: "MM-DD-YYYY" appointments = data.appointments;
}); }
const holidays = appointments ? appointments.map((h) => dayjs(h.start).format("MM-DD-YYYY")) : [];
const workingWeekdays = translateSettingsToWorkingDays(bodyshop.workingdays);
// Set holidays and working weekdays
setHolidays(holidays);
setWorkingWeekdays(workingWeekdays);
} finally {
setLoading(false); // Set loading to false after processing
}
} }
setDayJSSettings(); setDayJSSettings();
// Cleanup on unmount
return () => {
clearHolidays();
clearWorkingWeekdays();
};
}, [client, bodyshop]); }, [client, bodyshop]);
if (loading) {
return (
<Row justify="center" align="middle" style={{ minHeight: "100vh" }}>
<Spin size="large" />
</Row>
);
}
return ( return (
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col span={24}> <Col span={24}>
<ScoreboardTargetsTable scoreBoardlist={scoreBoardlist} /> <ScoreboardTargetsTable scoreBoardlist={scoreBoardlist} />
</Col> </Col>
<Col span={24}> <Col span={24}>
<ScoreboardLastDays sbEntriesByDate={sbEntriesByDate} /> <ScoreboardLastDays sbEntriesByDate={sbEntriesByDate} />
</Col> </Col>
<Col span={24}> <Col span={24}>
<ScoreboardChart sbEntriesByDate={sbEntriesByDate} /> <ScoreboardChart sbEntriesByDate={sbEntriesByDate} />
</Col> </Col>
@@ -92,27 +107,12 @@ export function ScoreboardDisplayComponent({ bodyshop }) {
function translateSettingsToWorkingDays(workingdays) { function translateSettingsToWorkingDays(workingdays) {
const days = []; const days = [];
if (workingdays.monday) days.push(1);
if (workingdays.monday) { if (workingdays.tuesday) days.push(2);
days.push(1); if (workingdays.wednesday) days.push(3);
} if (workingdays.thursday) days.push(4);
if (workingdays.tuesday) { if (workingdays.friday) days.push(5);
days.push(2); if (workingdays.saturday) days.push(6);
} if (workingdays.sunday) days.push(0);
if (workingdays.wednesday) {
days.push(3);
}
if (workingdays.thursday) {
days.push(4);
}
if (workingdays.friday) {
days.push(5);
}
if (workingdays.saturday) {
days.push(6);
}
if (workingdays.sunday) {
days.push(0);
}
return days; return days;
} }

View File

@@ -1,4 +1,3 @@
import React from "react";
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 { selectBodyshop } from "../../redux/user/user.selectors";
@@ -10,7 +9,7 @@ import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
@@ -26,7 +25,7 @@ export function ScoreboardLastDays({ bodyshop, sbEntriesByDate }) {
<Row> <Row>
{ArrayOfDate.map((a) => ( {ArrayOfDate.map((a) => (
<Col span={2} key={a}> <Col span={2} key={a}>
{!!sbEntriesByDate ? <ScoreboardDayStat date={a} entries={sbEntriesByDate[a] || []} /> : <LoadingSkeleton />} {sbEntriesByDate ? <ScoreboardDayStat date={a} entries={sbEntriesByDate[a] || []} /> : <LoadingSkeleton />}
</Col> </Col>
))} ))}
</Row> </Row>

View File

@@ -1,8 +1,8 @@
import { CalendarOutlined } from "@ant-design/icons"; import { CalendarOutlined } from "@ant-design/icons";
import { Card, Col, Divider, Row, Statistic } from "antd"; import { Card, Col, Divider, Row, Statistic } from "antd";
import _ from "lodash"; import { groupBy } from "lodash";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import React, { useMemo } from "react"; import { useMemo } 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";
@@ -13,7 +13,7 @@ import * as Util from "./scoreboard-targets-table.util";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
@@ -24,7 +24,7 @@ export function ScoreboardTargetsTable({ bodyshop, scoreBoardlist }) {
const { t } = useTranslation(); const { t } = useTranslation();
const values = useMemo(() => { const values = useMemo(() => {
const dateHash = _.groupBy(scoreBoardlist, "date"); const dateHash = groupBy(scoreBoardlist, "date");
let ret = { let ret = {
todayBody: 0, todayBody: 0,
@@ -213,4 +213,5 @@ export function ScoreboardTargetsTable({ bodyshop, scoreBoardlist }) {
</Card> </Card>
); );
} }
export default connect(mapStateToProps, mapDispatchToProps)(ScoreboardTargetsTable); export default connect(mapStateToProps, mapDispatchToProps)(ScoreboardTargetsTable);

View File

@@ -1,29 +1,172 @@
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
export const CalculateWorkingDaysThisMonth = () => dayjs().endOf("month").businessDaysInMonth().length; const DEFAULT_WORKING_DAYS = [1, 2, 3, 4, 5]; // Default to Monday-Friday
export const CalculateWorkingDaysInPeriod = (start, end) => dayjs(end).businessDiff(dayjs(start)); // Module-level state for holidays and working weekdays
let holidays = [];
let workingWeekdays = DEFAULT_WORKING_DAYS;
export const CalculateWorkingDaysAsOfToday = () => dayjs().endOf("day").businessDiff(dayjs().startOf("month")); /**
* Sets the holidays for the business logic.
* @param newHolidays
*/
export const setHolidays = (newHolidays = []) => {
holidays = newHolidays;
};
export const CalculateWorkingDaysLastMonth = () => /**
dayjs().subtract(1, "month").endOf("month").businessDaysInMonth().length; * Clears the holidays.
*/
export const clearHolidays = () => {
holidays = [];
};
/**
* Sets the working weekdays for the business logic.
* @param newWorkingWeekdays
*/
export const setWorkingWeekdays = (newWorkingWeekdays = DEFAULT_WORKING_DAYS) => {
workingWeekdays = newWorkingWeekdays;
};
/**
* Clears the working weekdays, resetting to default (Monday-Friday).
*/
export const clearWorkingWeekdays = () => {
workingWeekdays = DEFAULT_WORKING_DAYS; // Reset to default
};
/**
* Translates the bodyshop working days settings to an array of weekdays.
* @returns {*[]}
*/
export const getHolidays = () => {
return holidays;
};
/**
* Translates the working days settings from the bodyshop to an array of weekdays.
* @returns {number[]}
*/
export const getWorkingWeekdays = () => {
return workingWeekdays;
};
/**
* Calculates the number of working days in the current month, excluding holidays.
* @returns {number}
* @constructor
*/
export const CalculateWorkingDaysThisMonth = () => {
const businessDays = dayjs().businessDaysInMonth();
return businessDays.filter((day) => !holidays.includes(dayjs(day).format("MM-DD-YYYY"))).length;
};
/**
* Calculates the number of working days in a given period, excluding holidays.
* @param start
* @param end
* @returns {number}
* @constructor
*/
export const CalculateWorkingDaysInPeriod = (start, end) => {
let businessDays = dayjs(end).businessDiff(dayjs(start));
if (dayjs(end).isBusinessDay() && !holidays.includes(dayjs(end).format("MM-DD-YYYY"))) {
businessDays += 1;
}
return businessDays;
};
/**
* Calculates the number of working days as of today, excluding holidays.
* @returns {number}
* @constructor
*/
export const CalculateWorkingDaysAsOfToday = () => {
const today = dayjs().startOf("day");
let businessDays = today.businessDiff(dayjs().startOf("month"));
if (today.isBusinessDay() && !holidays.includes(today.format("MM-DD-YYYY"))) {
businessDays += 1;
}
return businessDays;
};
/**
* Calculates the number of working days in the last month, excluding holidays.
* @returns {number}
* @constructor
*/
export const CalculateWorkingDaysLastMonth = () => {
const businessDays = dayjs().subtract(1, "month").businessDaysInMonth();
return businessDays.filter((day) => !holidays.includes(dayjs(day).format("MM-DD-YYYY"))).length;
};
/**
* Calculates the weekly target hours based on daily target hours and the number of working days in the current week.
* @param dailyTargetHrs
* @returns {number}
* @constructor
*/
export const WeeklyTargetHrs = (dailyTargetHrs) => export const WeeklyTargetHrs = (dailyTargetHrs) =>
dailyTargetHrs * CalculateWorkingDaysInPeriod(dayjs().startOf("week"), dayjs().endOf("week")); dailyTargetHrs * CalculateWorkingDaysInPeriod(dayjs().startOf("week"), dayjs().endOf("week"));
/**
* Calculates the weekly target hours for a specific period.
* @param dailyTargetHrs
* @param start
* @param end
* @returns {number}
* @constructor
*/
export const WeeklyTargetHrsInPeriod = (dailyTargetHrs, start, end) => export const WeeklyTargetHrsInPeriod = (dailyTargetHrs, start, end) =>
dailyTargetHrs * CalculateWorkingDaysInPeriod(start, end); dailyTargetHrs * CalculateWorkingDaysInPeriod(start, end);
/**
* Calculates the monthly target hours based on daily target hours and the number of working days in the current month.
* @param dailyTargetHrs
* @returns {number}
* @constructor
*/
export const MonthlyTargetHrs = (dailyTargetHrs) => dailyTargetHrs * CalculateWorkingDaysThisMonth(); export const MonthlyTargetHrs = (dailyTargetHrs) => dailyTargetHrs * CalculateWorkingDaysThisMonth();
/**
* Calculates the monthly target hours for the last month based on daily target hours and the number of working days
* in the last month.
* @param dailyTargetHrs
* @returns {number}
* @constructor
*/
export const LastMonthTargetHrs = (dailyTargetHrs) => dailyTargetHrs * CalculateWorkingDaysLastMonth(); export const LastMonthTargetHrs = (dailyTargetHrs) => dailyTargetHrs * CalculateWorkingDaysLastMonth();
/**
* Calculates the target hours as of today based on daily target hours and the number of working days as of today.
* @param dailyTargetHrs
* @returns {number}
* @constructor
*/
export const AsOfTodayTargetHrs = (dailyTargetHrs) => dailyTargetHrs * CalculateWorkingDaysAsOfToday(); export const AsOfTodayTargetHrs = (dailyTargetHrs) => dailyTargetHrs * CalculateWorkingDaysAsOfToday();
export const AsOfDateTargetHours = (dailyTargetHours, date) => /**
dailyTargetHours * dayjs(date).businessDiff(dayjs().startOf("month")); * Calculates the target hours as of a specific date based on daily target hours and the number of business days up to
* that date.
* @param dailyTargetHours
* @param date
* @returns {number}
* @constructor
*/
export const AsOfDateTargetHours = (dailyTargetHours, date) => {
let businessDays = dayjs(date).businessDiff(dayjs().startOf("month"));
if (dayjs(date).isBusinessDay() && !holidays.includes(dayjs(date).format("MM-DD-YYYY"))) {
businessDays += 1;
}
return dailyTargetHours * businessDays;
};
/**
* Generates a list of all days in the current month.
* @returns {*[]}
* @constructor
*/
export const ListOfDaysInCurrentMonth = () => { export const ListOfDaysInCurrentMonth = () => {
const days = []; const days = [];
let dateStart = dayjs().startOf("month"); let dateStart = dayjs().startOf("month");
@@ -36,6 +179,13 @@ export const ListOfDaysInCurrentMonth = () => {
return days; return days;
}; };
/**
* Generates a list of all days between two dates.
* @param start
* @param end
* @returns {*[]}
* @constructor
*/
export const ListDaysBetween = ({ start, end }) => { export const ListDaysBetween = ({ start, end }) => {
const days = []; const days = [];
let dateStart = dayjs(start); let dateStart = dayjs(start);

View File

@@ -1,13 +1,15 @@
import { DeleteFilled } from "@ant-design/icons"; import { DeleteFilled } from "@ant-design/icons";
import { useApolloClient, useMutation, useQuery } from "@apollo/client"; import { useApolloClient, useMutation, useQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Card, Form, Input, InputNumber, Select, Switch, Table } from "antd"; import { Button, Card, Form, Input, InputNumber, Select, Switch, Table } from "antd";
import { useForm } from "antd/es/form/Form"; import { useForm } from "antd/es/form/Form";
import dayjs from "../../utils/day"; import queryString from "query-string";
import React, { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
import { import {
CHECK_EMPLOYEE_NUMBER, CHECK_EMPLOYEE_NUMBER,
@@ -20,19 +22,17 @@ import {
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import CiecaSelect from "../../utils/Ciecaselect"; import CiecaSelect from "../../utils/Ciecaselect";
import { DateFormatter } from "../../utils/DateFormatter"; import { DateFormatter } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component"; import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import ShopEmployeeAddVacation from "./shop-employees-add-vacation.component"; import ShopEmployeeAddVacation from "./shop-employees-add-vacation.component";
import queryString from "query-string";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
@@ -83,7 +83,7 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
} }
} }
}) })
.then((r) => { .then(() => {
notification["success"]({ notification["success"]({
message: t("employees.successes.save") message: t("employees.successes.save")
}); });
@@ -120,13 +120,13 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
title: t("employees.fields.vacation.start"), title: t("employees.fields.vacation.start"),
dataIndex: "start", dataIndex: "start",
key: "start", key: "start",
render: (text, record) => <DateFormatter>{text}</DateFormatter> render: (text) => <DateFormatter>{text}</DateFormatter>
}, },
{ {
title: t("employees.fields.vacation.end"), title: t("employees.fields.vacation.end"),
dataIndex: "end", dataIndex: "end",
key: "end", key: "end",
render: (text, record) => <DateFormatter>{text}</DateFormatter> render: (text) => <DateFormatter>{text}</DateFormatter>
}, },
{ {
title: t("employees.fields.vacation.length"), title: t("employees.fields.vacation.length"),
@@ -210,7 +210,7 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
required: true required: true
//message: t("general.validation.required"), //message: t("general.validation.required"),
}, },
({ getFieldValue }) => ({ () => ({
async validator(rule, value) { async validator(rule, value) {
if (value) { if (value) {
const response = await client.query({ const response = await client.query({
@@ -369,8 +369,9 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
add(); add();
}} }}
style={{ width: "100%" }} style={{ width: "100%" }}
id="add-employee-rate-button"
> >
{t("employees.actions.newrate")} <span id="new-employee-rate">{t("employees.actions.newrate")}</span>
</Button> </Button>
</Form.Item> </Form.Item>
</div> </div>
@@ -383,7 +384,7 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
title={() => <ShopEmployeeAddVacation employee={data && data.employees_by_pk} />} title={() => <ShopEmployeeAddVacation employee={data && data.employees_by_pk} />}
columns={columns} columns={columns}
rowKey={"id"} rowKey={"id"}
dataSource={data ? data.employees_by_pk.employee_vacations : []} dataSource={data?.employees_by_pk?.employee_vacations ?? []}
/> />
</Card> </Card>
); );

View File

@@ -1,35 +1,34 @@
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Card, Tabs } from "antd"; import { Button, Card, Tabs } from "antd";
import React from "react"; import queryString from "query-string";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import ShopInfoGeneral from "./shop-info.general.component"; import ShopInfoGeneral from "./shop-info.general.component";
import ShopInfoIntakeChecklistComponent from "./shop-info.intake.component"; import ShopInfoIntakeChecklistComponent from "./shop-info.intake.component";
import ShopInfoLaborRates from "./shop-info.laborrates.component"; import ShopInfoLaborRates from "./shop-info.laborrates.component";
import ShopInfoNotificationsAutoadd from "./shop-info.notifications-autoadd.component.jsx";
import ShopInfoOrderStatusComponent from "./shop-info.orderstatus.component"; import ShopInfoOrderStatusComponent from "./shop-info.orderstatus.component";
import ShopInfoPartsScan from "./shop-info.parts-scan"; import ShopInfoPartsScan from "./shop-info.parts-scan";
import ShopInfoRbacComponent from "./shop-info.rbac.component"; import ShopInfoRbacComponent from "./shop-info.rbac.component";
import ShopInfoResponsibilityCenterComponent from "./shop-info.responsibilitycenters.component"; import ShopInfoResponsibilityCenterComponent from "./shop-info.responsibilitycenters.component";
import ShopInfoRoGuard from "./shop-info.roguard.component";
import ShopInfoROStatusComponent from "./shop-info.rostatus.component"; import ShopInfoROStatusComponent from "./shop-info.rostatus.component";
import ShopInfoSchedulingComponent from "./shop-info.scheduling.component"; import ShopInfoSchedulingComponent from "./shop-info.scheduling.component";
import ShopInfoSpeedPrint from "./shop-info.speedprint.component"; import ShopInfoSpeedPrint from "./shop-info.speedprint.component";
import { useLocation, useNavigate } from "react-router-dom";
import ShopInfoTaskPresets from "./shop-info.task-presets.component"; import ShopInfoTaskPresets from "./shop-info.task-presets.component";
import queryString from "query-string";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import ShopInfoRoGuard from "./shop-info.roguard.component";
import ShopInfoIntellipay from "./shop-intellipay-config.component"; import ShopInfoIntellipay from "./shop-intellipay-config.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import ShopInfoNotificationsAutoadd from "./shop-info.notifications-autoadd.component.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoComponent); export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoComponent);
@@ -158,7 +157,7 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
return ( return (
<Card <Card
extra={ extra={
<Button type="primary" loading={saveLoading} onClick={() => form.submit()}> <Button type="primary" loading={saveLoading} onClick={() => form.submit()} id="shop-info-save-button">
{t("general.actions.save")} {t("general.actions.save")}
</Button> </Button>
} }

View File

@@ -0,0 +1,25 @@
import { Typography } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import PhoneNumberConsentList from "../phone-number-consent/phone-number-consent.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({});
function ShopInfoConsentComponent({ bodyshop }) {
const { t } = useTranslation();
return (
<div>
<Typography.Title level={4}>{t("settings.title")}</Typography.Title>
{<PhoneNumberConsentList bodyshop={bodyshop} />}
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoConsentComponent);

View File

@@ -1,7 +1,6 @@
import { DeleteFilled } from "@ant-design/icons"; import { DeleteFilled } from "@ant-design/icons";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, DatePicker, Form, Input, InputNumber, Radio, Select, Space, Switch } from "antd"; import { Button, DatePicker, Form, Input, InputNumber, Radio, Select, Space, Switch } from "antd";
import React 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";
@@ -15,11 +14,12 @@ import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-forma
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component"; import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
// eslint-disable-next-line no-undef
const timeZonesList = Intl.supportedValuesOf("timeZone"); const timeZonesList = Intl.supportedValuesOf("timeZone");
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoGeneral); export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoGeneral);
@@ -144,236 +144,246 @@ export function ShopInfoGeneral({ form, bodyshop }) {
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<FeatureWrapper featureName="export" noauth={() => null}> <LayoutFormRow header={t("bodyshop.labels.accountingsetup")} id="accountingsetup">
<LayoutFormRow header={t("bodyshop.labels.accountingsetup")} id="accountingsetup"> {HasFeatureAccess({ featureName: "export", bodyshop }) && (
<Form.Item label={t("bodyshop.labels.qbo")} valuePropName="checked" name={["accountingconfig", "qbo"]}> <>
<Switch /> <Form.Item label={t("bodyshop.labels.qbo")} valuePropName="checked" name={["accountingconfig", "qbo"]}>
</Form.Item> <Switch />
{InstanceRenderManager({ </Form.Item>
imex: ( {InstanceRenderManager({
<Form.Item shouldUpdate noStyle> imex: (
{() => ( <Form.Item shouldUpdate noStyle>
{() => (
<Form.Item
label={t("bodyshop.labels.qbo_usa")}
shouldUpdate
valuePropName="checked"
name={["accountingconfig", "qbo_usa"]}
>
<Switch disabled={!form.getFieldValue(["accountingconfig", "qbo"])} />
</Form.Item>
)}
</Form.Item>
)
})}
<Form.Item label={t("bodyshop.labels.qbo_departmentid")} name={["accountingconfig", "qbo_departmentid"]}>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.labels.accountingtiers")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["accountingconfig", "tiers"]}
>
<Radio.Group>
<Radio value={2}>2</Radio>
<Radio value={3}>3</Radio>
</Radio.Group>
</Form.Item>
<Form.Item shouldUpdate>
{() => {
return (
<Form.Item <Form.Item
label={t("bodyshop.labels.qbo_usa")} label={t("bodyshop.labels.2tiersetup")}
shouldUpdate shouldUpdate
valuePropName="checked" rules={[
name={["accountingconfig", "qbo_usa"]} {
required: form.getFieldValue(["accountingconfig", "tiers"]) === 2
//message: t("general.validation.required"),
}
]}
name={["accountingconfig", "twotierpref"]}
> >
<Switch disabled={!form.getFieldValue(["accountingconfig", "qbo"])} /> <Radio.Group disabled={form.getFieldValue(["accountingconfig", "tiers"]) === 3}>
<Radio value="name">{t("bodyshop.labels.2tiername")}</Radio>
<Radio value="source">{t("bodyshop.labels.2tiersource")}</Radio>
</Radio.Group>
</Form.Item> </Form.Item>
)} );
</Form.Item> }}
) </Form.Item>
})} <Form.Item
<Form.Item label={t("bodyshop.labels.qbo_departmentid")} name={["accountingconfig", "qbo_departmentid"]}> label={t("bodyshop.labels.printlater")}
<Input /> valuePropName="checked"
</Form.Item> name={["accountingconfig", "printlater"]}
<Form.Item >
label={t("bodyshop.labels.accountingtiers")} <Switch />
rules={[ </Form.Item>
{ <Form.Item
required: true label={t("bodyshop.labels.emaillater")}
//message: t("general.validation.required"), valuePropName="checked"
} name={["accountingconfig", "emaillater"]}
]} >
name={["accountingconfig", "tiers"]} <Switch />
> </Form.Item>
<Radio.Group> </>
<Radio value={2}>2</Radio> )}
<Radio value={3}>3</Radio> <Form.Item
</Radio.Group> label={t("bodyshop.fields.inhousevendorid")}
</Form.Item> name={"inhousevendorid"}
<Form.Item shouldUpdate> rules={[
{() => { {
return ( required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.default_adjustment_rate")}
name={"default_adjustment_rate"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={0} precision={2} />
</Form.Item>
{InstanceRenderManager({
imex: (
<Form.Item label={t("bodyshop.fields.federal_tax_id")} name="federal_tax_id">
<Input />
</Form.Item>
)
})}
<Form.Item label={t("bodyshop.fields.state_tax_id")} name="state_tax_id">
<Input />
</Form.Item>
{HasFeatureAccess({ featureName: "bills", bodyshop }) && (
<>
{InstanceRenderManager({
imex: (
<Form.Item <Form.Item
label={t("bodyshop.labels.2tiersetup")} label={t("bodyshop.fields.invoice_federal_tax_rate")}
shouldUpdate name={["bill_tax_rates", "federal_tax_rate"]}
rules={[ rules={[
{ {
required: form.getFieldValue(["accountingconfig", "tiers"]) === 2 required: true
//message: t("general.validation.required"), //message: t("general.validation.required"),
} }
]} ]}
name={["accountingconfig", "twotierpref"]}
> >
<Radio.Group disabled={form.getFieldValue(["accountingconfig", "tiers"]) === 3}> <InputNumber />
<Radio value="name">{t("bodyshop.labels.2tiername")}</Radio>
<Radio value="source">{t("bodyshop.labels.2tiersource")}</Radio>
</Radio.Group>
</Form.Item> </Form.Item>
); )
}} })}
</Form.Item> <Form.Item
<Form.Item label={t("bodyshop.fields.invoice_state_tax_rate")}
label={t("bodyshop.labels.printlater")} name={["bill_tax_rates", "state_tax_rate"]}
valuePropName="checked" rules={[
name={["accountingconfig", "printlater"]} {
> required: true
<Switch /> //message: t("general.validation.required"),
</Form.Item> }
<Form.Item ]}
label={t("bodyshop.labels.emaillater")} >
valuePropName="checked" <InputNumber />
name={["accountingconfig", "emaillater"]} </Form.Item>
> <Form.Item
<Switch /> label={t("bodyshop.fields.invoice_local_tax_rate")}
</Form.Item> name={["bill_tax_rates", "local_tax_rate"]}
<Form.Item rules={[
label={t("bodyshop.fields.inhousevendorid")} {
name={"inhousevendorid"} required: true
rules={[ //message: t("general.validation.required"),
{ }
required: true ]}
//message: t("general.validation.required"), >
} <InputNumber />
]} </Form.Item>
> </>
<Input /> )}
</Form.Item> <Form.Item
<Form.Item name={["md_payment_types"]}
label={t("bodyshop.fields.default_adjustment_rate")} label={t("bodyshop.fields.md_payment_types")}
name={"default_adjustment_rate"} rules={[
rules={[ {
{ required: true,
required: true //message: t("general.validation.required"),
//message: t("general.validation.required"), type: "array"
} }
]} ]}
> >
<InputNumber min={0} precision={2} /> <Select mode="tags" />
</Form.Item> </Form.Item>
{InstanceRenderManager({ <Form.Item
imex: ( name={["md_categories"]}
<Form.Item label={t("bodyshop.fields.federal_tax_id")} name="federal_tax_id"> label={t("bodyshop.fields.md_categories")}
rules={[
{
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>
{HasFeatureAccess({ featureName: "export", bodyshop }) && (
<>
<Form.Item
name={["accountingconfig", "ReceivableCustomField1"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 1 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
name={["accountingconfig", "ReceivableCustomField2"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 2 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
name={["accountingconfig", "ReceivableCustomField3"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 3 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
name={["md_classes"]}
label={t("bodyshop.fields.md_classes")}
rules={[
({ getFieldValue }) => {
return {
required: getFieldValue("enforce_class"),
//message: t("general.validation.required"),
type: "array"
};
}
]}
>
<Select mode="tags" />
</Form.Item>
<Form.Item name={["enforce_class"]} label={t("bodyshop.fields.enforce_class")} valuePropName="checked">
<Switch />
</Form.Item>
{ClosingPeriod.treatment === "on" && (
<Form.Item
name={["accountingconfig", "ClosingPeriod"]}
label={t("bodyshop.fields.closingperiod")} //{t("reportcenter.labels.dates")}
>
<DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} />
</Form.Item>
)}
{ADPPayroll.treatment === "on" && (
<Form.Item name={["accountingconfig", "companyCode"]} label={t("bodyshop.fields.companycode")}>
<Input /> <Input />
</Form.Item> </Form.Item>
) )}
})} {ADPPayroll.treatment === "on" && (
<Form.Item label={t("bodyshop.fields.state_tax_id")} name="state_tax_id"> <Form.Item name={["accountingconfig", "batchID"]} label={t("bodyshop.fields.batchid")}>
<Input /> <Input />
</Form.Item>
{InstanceRenderManager({
imex: (
<Form.Item
label={t("bodyshop.fields.invoice_federal_tax_rate")}
name={["bill_tax_rates", "federal_tax_rate"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item> </Form.Item>
) )}
})} </>
<Form.Item )}
label={t("bodyshop.fields.invoice_state_tax_rate")} </LayoutFormRow>
name={["bill_tax_rates", "state_tax_rate"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.invoice_local_tax_rate")}
name={["bill_tax_rates", "local_tax_rate"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
<Form.Item
name={["md_payment_types"]}
label={t("bodyshop.fields.md_payment_types")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>
<Form.Item
name={["md_categories"]}
label={t("bodyshop.fields.md_categories")}
rules={[
{
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>
<Form.Item name={["enforce_class"]} label={t("bodyshop.fields.enforce_class")} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
name={["accountingconfig", "ReceivableCustomField1"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 1 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
name={["accountingconfig", "ReceivableCustomField2"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 2 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
name={["accountingconfig", "ReceivableCustomField3"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 3 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
name={["md_classes"]}
label={t("bodyshop.fields.md_classes")}
rules={[
({ getFieldValue }) => {
return {
required: getFieldValue("enforce_class"),
//message: t("general.validation.required"),
type: "array"
};
}
]}
>
<Select mode="tags" />
</Form.Item>
{ClosingPeriod.treatment === "on" && (
<Form.Item
name={["accountingconfig", "ClosingPeriod"]}
label={t("bodyshop.fields.closingperiod")} //{t("reportcenter.labels.dates")}
>
<DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} />
</Form.Item>
)}
{ADPPayroll.treatment === "on" && (
<Form.Item name={["accountingconfig", "companyCode"]} label={t("bodyshop.fields.companycode")}>
<Input />
</Form.Item>
)}
{ADPPayroll.treatment === "on" && (
<Form.Item name={["accountingconfig", "batchID"]} label={t("bodyshop.fields.batchid")}>
<Input />
</Form.Item>
)}
</LayoutFormRow>
</FeatureWrapper>
<FeatureWrapper featureName="scoreboard" noauth={() => null}> <FeatureWrapper featureName="scoreboard" noauth={() => null}>
<LayoutFormRow header={t("bodyshop.labels.scoreboardsetup")} id="scoreboardsetup"> <LayoutFormRow header={t("bodyshop.labels.scoreboardsetup")} id="scoreboardsetup">
<Form.Item <Form.Item
@@ -823,7 +833,11 @@ export function ShopInfoGeneral({ form, bodyshop }) {
}} }}
</Form.List> </Form.List>
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow grow header={t("bodyshop.labels.insurancecos")} id="insurancecos"> <LayoutFormRow
grow
header=<span id="insurancecos-header">{t("bodyshop.labels.insurancecos")}</span>
id="insurancecos"
>
<Form.List name={["md_ins_cos"]}> <Form.List name={["md_ins_cos"]}>
{(fields, { add, remove, move }) => { {(fields, { add, remove, move }) => {
return ( return (

View File

@@ -13,7 +13,7 @@ import { ColorPicker } from "./shop-info.rostatus.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
@@ -22,7 +22,7 @@ export function ShopInfoSchedulingComponent({ form, bodyshop }) {
return ( return (
<div> <div>
<LayoutFormRow> <LayoutFormRow id="shopinfo-scheduling">
<Form.Item <Form.Item
label={t("bodyshop.fields.appt_length")} label={t("bodyshop.fields.appt_length")}
name={"appt_length"} name={"appt_length"}
@@ -44,6 +44,7 @@ export function ShopInfoSchedulingComponent({ form, bodyshop }) {
//message: t("general.validation.required"), //message: t("general.validation.required"),
} }
]} ]}
id="schedule_start_time"
> >
<TimePicker disableSeconds={true} format="HH:mm" /> <TimePicker disableSeconds={true} format="HH:mm" />
</Form.Item> </Form.Item>
@@ -56,6 +57,7 @@ export function ShopInfoSchedulingComponent({ form, bodyshop }) {
//message: t("general.validation.required"), //message: t("general.validation.required"),
} }
]} ]}
id="schedule_end_time"
> >
<TimePicker disableSeconds={true} format="HH:mm" /> <TimePicker disableSeconds={true} format="HH:mm" />
</Form.Item> </Form.Item>

View File

@@ -16,5 +16,6 @@ export default connect(mapStateToProps, mapDispatchToProps)(ShopSubStatus);
export function ShopSubStatus({ bodyshop }) { export function ShopSubStatus({ bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { sub_status } = bodyshop; const { sub_status } = bodyshop;
return <Result status="403" title={t(`general.labels.sub_status.${sub_status}`)} />; // expired trail-expired' are the valid sub_status values
return <Result status="403" title={t(`general.errors.sub_status.${sub_status}`)} />;
} }

View File

@@ -0,0 +1,156 @@
import { Virtuoso } from "react-virtuoso";
import { Badge, Button, Spin } from "antd";
import { useTranslation } from "react-i18next";
import { forwardRef, useMemo, useRef } from "react";
import day from "../../utils/day.js";
import "./task-center.styles.scss";
import {
ArrowRightOutlined,
CalendarOutlined,
ClockCircleOutlined,
PlusCircleOutlined,
QuestionCircleOutlined
} from "@ant-design/icons";
const TaskCenterComponent = forwardRef(
({ visible, tasks, loading, error, onTaskClick, onLoadMore, hasMore, createNewTask, incompleteTaskCount }, ref) => {
const { t } = useTranslation();
const virtuosoRef = useRef(null);
const sectionIcons = {
[t("tasks.labels.overdue")]: <ClockCircleOutlined style={{ marginRight: 8 }} />,
[t("tasks.labels.due_today")]: <CalendarOutlined style={{ marginRight: 8 }} />,
[t("tasks.labels.upcoming")]: <ArrowRightOutlined style={{ marginRight: 8 }} />,
[t("tasks.labels.no_due_date")]: <QuestionCircleOutlined style={{ marginRight: 8 }} />
};
const groups = useMemo(() => {
const now = day();
const today = now.startOf("day");
const overdue = tasks.filter((t) => t.due_date && day(t.due_date).isBefore(today));
const dueToday = tasks.filter((t) => t.due_date && day(t.due_date).isSame(today, "day"));
const upcoming = tasks.filter(
(t) => t.due_date && day(t.due_date).isAfter(today) && !day(t.due_date).isSame(today, "day")
);
const noDueDate = tasks.filter((t) => !t.due_date);
return [
{ label: t("tasks.labels.overdue"), tasks: overdue },
{ label: t("tasks.labels.due_today"), tasks: dueToday },
{ label: t("tasks.labels.upcoming"), tasks: upcoming },
{ label: t("tasks.labels.no_due_date"), tasks: noDueDate }
].filter((group) => group.tasks.length > 0);
}, [tasks, t]);
const groupCounts = useMemo(() => groups.map((group) => group.tasks.length), [groups]);
const flatTasks = useMemo(() => groups.flatMap((group) => group.tasks), [groups]);
const priorityColors = {
1: "red",
2: "orange",
3: "green"
};
const getPriorityColor = (priority) => priorityColors[priority] || null;
const groupContent = (groupIndex) => {
const { label, tasks } = groups[groupIndex];
let displayCount = tasks.length;
if (label === t("tasks.labels.no_due_date")) {
displayCount =
incompleteTaskCount -
groups.reduce((sum, group, idx) => (idx !== groupIndex ? sum + group.tasks.length : sum), 0);
}
return (
<div className="section-title">
{sectionIcons[label]}
{label} ({displayCount})
</div>
);
};
const itemContent = (index) => {
const task = flatTasks[index];
const priorityColor = getPriorityColor(task.priority);
return (
<div
className="task-row"
onClick={() => onTaskClick(task.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onTaskClick(task.id);
}
}}
>
<div className="task-title-cell">
<div className="task-row-container">
<div className="task-title">{task.title}</div>
<div className="task-ro-number">
{t("tasks.labels.ro-number", {
ro_number: task.job?.ro_number || t("general.labels.na")
})}
</div>
</div>
</div>
<div className="task-due-cell">
{task.due_date && <span>{day(task.due_date).fromNow()}</span>}
{!!priorityColor && <Badge color={priorityColor} dot style={{ marginLeft: 6 }} />}
</div>
</div>
);
};
if (error) {
return (
<div className={`task-center ${visible ? "visible" : ""}`} ref={ref}>
<div className="task-header">
<h3>{t("tasks.labels.my_tasks_center")}</h3>
</div>
<div className="error-message">{t("tasks.errors.load_failed")}</div>
</div>
);
}
return (
<div className={`task-center ${visible ? "visible" : ""}`} ref={ref}>
<div className="task-header">
<Badge count={incompleteTaskCount} size="medium" offset={[13, -5]}>
<h3>{t("tasks.labels.my_tasks_center")}</h3>
</Badge>
<div className="task-header-actions">
<Button className="create-task-button" type="link" icon={<PlusCircleOutlined />} onClick={createNewTask} />
{loading && <Spin spinning={loading} size="small" />}
</div>
</div>
{tasks.length === 0 && !loading ? (
<div className="no-tasks-message">{t("tasks.labels.no_tasks")}</div>
) : (
<Virtuoso
ref={virtuosoRef}
style={{ height: "550px", width: "100%" }}
groupCounts={groupCounts}
groupContent={groupContent}
itemContent={itemContent}
endReached={hasMore && !loading ? onLoadMore : undefined}
components={{
Footer: () =>
loading ? (
<div className="loading-footer">
<Spin />
</div>
) : null
}}
/>
)}
</div>
);
}
);
TaskCenterComponent.displayName = "TaskCenterComponent";
export default TaskCenterComponent;

View File

@@ -0,0 +1,135 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery } from "@apollo/client";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { INITIAL_TASKS, TASKS_CENTER_POLL_INTERVAL, useSocket } from "../../contexts/SocketIO/useSocket";
import { useIsEmployee } from "../../utils/useIsEmployee";
import TaskCenterComponent from "./task-center.component";
import { setModalContext } from "../../redux/modals/modals.actions";
import { QUERY_TASKS_NO_DUE_DATE_PAGINATED, QUERY_TASKS_WITH_DUE_DATES } from "../../graphql/tasks.queries";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
});
const mapDispatchToProps = (dispatch) => ({
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" }))
});
const TaskCenterContainer = ({
visible,
onClose,
bodyshop,
currentUser,
setTaskUpsertContext,
incompleteTaskCount
}) => {
const [tasks, setTasks] = useState([]);
const { isConnected } = useSocket();
const isEmployee = useIsEmployee(bodyshop, currentUser);
const assignedToId = useMemo(() => {
const employee = bodyshop?.employees?.find((e) => e.user_email === currentUser?.email);
return employee?.id || null;
}, [bodyshop, currentUser]);
// Query 1: Tasks with due dates
const {
data: dueDateData,
loading: dueLoading,
error: dueError
} = useQuery(QUERY_TASKS_WITH_DUE_DATES, {
variables: {
bodyshop: bodyshop?.id,
assigned_to: assignedToId,
order: [{ due_date: "asc" }, { created_at: "desc" }]
},
skip: !bodyshop?.id || !assignedToId || !isEmployee || !currentUser?.email,
fetchPolicy: "cache-and-network",
pollInterval: isConnected ? 0 : TASKS_CENTER_POLL_INTERVAL
});
// Query 2: Tasks with no due date (paginated)
const {
data: noDueDateData,
loading: noDueLoading,
error: noDueError,
fetchMore
} = useQuery(QUERY_TASKS_NO_DUE_DATE_PAGINATED, {
variables: {
bodyshop: bodyshop?.id,
assigned_to: assignedToId,
order: [{ priority: "asc" }, { created_at: "desc" }],
limit: INITIAL_TASKS, // Adjust this constant as needed
offset: 0
},
skip: !bodyshop?.id || !assignedToId || !isEmployee || !currentUser?.email,
fetchPolicy: "cache-and-network",
pollInterval: isConnected ? 0 : TASKS_CENTER_POLL_INTERVAL
});
// Combine tasks from both queries
useEffect(() => {
const dueDateTasks = dueDateData?.tasks || [];
const noDueDateTasks = noDueDateData?.tasks || [];
setTasks([...dueDateTasks, ...noDueDateTasks]);
}, [dueDateData, noDueDateData]);
const noDueDateLength = noDueDateData?.tasks?.length || 0;
const totalNoDueDate = noDueDateData?.tasks_aggregate?.aggregate?.count || 0;
const hasMore = noDueDateLength < totalNoDueDate;
// Handle pagination for no-due-date tasks
const handleLoadMore = () => {
fetchMore({
variables: {
offset: noDueDateData?.tasks?.length || 0
},
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev;
return {
...prev,
tasks: [...prev.tasks, ...fetchMoreResult.tasks],
tasks_aggregate: fetchMoreResult.tasks_aggregate
};
}
});
};
const handleTaskClick = useCallback(
(id) => {
const task = tasks.find((t) => t.id === id);
if (task) {
setTaskUpsertContext({
context: {
existingTask: task
}
});
}
},
[tasks, setTaskUpsertContext]
);
const createNewTask = () => {
setTaskUpsertContext({ actions: {}, context: {} });
};
return (
<TaskCenterComponent
visible={visible}
onClose={onClose}
tasks={tasks}
loading={dueLoading || noDueLoading}
error={dueError || noDueError}
onTaskClick={handleTaskClick}
onLoadMore={handleLoadMore}
hasMore={hasMore}
createNewTask={createNewTask}
incompleteTaskCount={incompleteTaskCount}
/>
);
};
export default connect(mapStateToProps, mapDispatchToProps)(TaskCenterContainer);

View File

@@ -0,0 +1,147 @@
.task-center {
position: absolute;
top: 64px;
right: 0;
width: 500px;
max-width: 500px;
background: #fff;
color: rgba(0, 0, 0, 0.85);
border: 1px solid #d9d9d9;
border-radius: 6px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08), 0 3px 6px rgba(0, 0, 0, 0.06);
z-index: 1000;
display: none;
overflow-x: hidden;
&.visible {
display: block;
}
.task-header {
padding: 4px 10px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
background: #fafafa;
h3 {
font-size: 14px;
margin: 0;
}
.create-task-button {
border: none;
color: white;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
&:hover {
background-color: #40a9ff;
}
}
}
.task-section {
margin: 0;
padding: 0;
}
.section-title {
padding: 0px 10px;
margin: 0px;
//font-size: 12px;
background: #f5f5f5;
font-weight: 650;
border-bottom: 1px solid #e8e8e8;
position: sticky;
top: 0;
z-index: 1;
}
.task-row-container {
margin-top: 15px;
margin-bottom: 15px;
}
.task-row {
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: flex-start;
&:hover {
background: #f5f5f5;
}
.task-title-cell {
flex: 1;
padding: 6px 8px;
vertical-align: top;
//font-size: 12px;
line-height: 1.2;
max-width: 350px; // or whatever fits your layout
.task-title {
font-size: 16px;
font-weight: 550;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%; // Or a specific width if you want more control
display: inline-block;
vertical-align: middle;
}
.task-ro-number {
margin-top: 20px;
color: #1677ff;
}
}
.task-due-cell {
padding: 6px 8px;
vertical-align: top;
//font-size: 12px;
line-height: 1.2;
text-align: right;
white-space: nowrap;
color: rgba(0, 0, 0, 0.45);
}
}
button {
margin: 8px auto;
padding: 4px 10px;
background-color: #1677ff;
color: white;
border: none;
border-radius: 4px;
//font-size: 12px;
cursor: pointer;
&:hover {
background-color: #4096ff;
}
&:disabled {
background-color: #d9d9d9;
cursor: not-allowed;
}
}
.no-tasks-message,
.error-message {
padding: 16px;
text-align: center;
color: rgba(0, 0, 0, 0.45);
}
.loading-footer {
padding: 16px;
text-align: center;
}
}

View File

@@ -4,13 +4,12 @@ import {
DeleteFilled, DeleteFilled,
DeleteOutlined, DeleteOutlined,
EditFilled, EditFilled,
ExclamationCircleFilled,
PlusCircleFilled, PlusCircleFilled,
SyncOutlined SyncOutlined
} from "@ant-design/icons"; } from "@ant-design/icons";
import { Button, Card, Space, Switch, Table } from "antd"; import { Button, Card, Space, Switch, Table } from "antd";
import queryString from "query-string"; import queryString from "query-string";
import React, { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
@@ -19,6 +18,7 @@ import { pageLimit } from "../../utils/config";
import { DateFormatter, DateTimeFormatter } from "../../utils/DateFormatter.jsx"; import { DateFormatter, DateTimeFormatter } from "../../utils/DateFormatter.jsx";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx"; import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
import PriorityLabel from "../../utils/tasksPriorityLabel.jsx";
/** /**
* Task List Component * Task List Component
@@ -54,47 +54,12 @@ const RemindAtRecord = ({ remindAt }) => {
); );
}; };
/**
* Priority Label Component
* @param priority
* @returns {Element}
* @constructor
*/
const PriorityLabel = ({ priority }) => {
switch (priority) {
case 1:
return (
<div>
High <ExclamationCircleFilled style={{ marginLeft: "5px", color: "red" }} />
</div>
);
case 2:
return (
<div>
Medium <ExclamationCircleFilled style={{ marginLeft: "5px", color: "yellow" }} />
</div>
);
case 3:
return (
<div>
Low <ExclamationCircleFilled style={{ marginLeft: "5px", color: "green" }} />
</div>
);
default:
return (
<div>
None <ExclamationCircleFilled style={{ marginLeft: "5px" }} />
</div>
);
}
};
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
// Existing dispatch props... // Existing dispatch props...
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" })) setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" }))
}); });
const mapStateToProps = (state) => ({}); const mapStateToProps = () => ({});
export default connect(mapStateToProps, mapDispatchToProps)(TaskListComponent); export default connect(mapStateToProps, mapDispatchToProps)(TaskListComponent);

View File

@@ -4,7 +4,6 @@ import { useMutation, useQuery } from "@apollo/client";
import { MUTATION_TOGGLE_TASK_COMPLETED, MUTATION_TOGGLE_TASK_DELETED } from "../../graphql/tasks.queries.js"; import { MUTATION_TOGGLE_TASK_COMPLETED, MUTATION_TOGGLE_TASK_DELETED } from "../../graphql/tasks.queries.js";
import { pageLimit } from "../../utils/config.js"; import { pageLimit } from "../../utils/config.js";
import AlertComponent from "../alert/alert.component.jsx"; import AlertComponent from "../alert/alert.component.jsx";
import React from "react";
import TaskListComponent from "./task-list.component.jsx"; import TaskListComponent from "./task-list.component.jsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect, useDispatch } from "react-redux"; import { connect, useDispatch } from "react-redux";
@@ -20,7 +19,7 @@ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser currentUser: selectCurrentUser
}); });
const mapDispatchToProps = (dispatch) => ({}); const mapDispatchToProps = () => ({});
export default connect(mapStateToProps, mapDispatchToProps)(TaskListContainer); export default connect(mapStateToProps, mapDispatchToProps)(TaskListContainer);
@@ -55,8 +54,8 @@ export function TaskListContainer({
bodyshop: bodyshop.id, bodyshop: bodyshop.id,
[relationshipType]: relationshipId, [relationshipType]: relationshipId,
deleted: deleted === "true", deleted: deleted === "true",
completed: completed === "true", //TODO: Find where mine is set. completed: completed === "true",
assigned_to: onlyMine ? bodyshop?.employees?.find((e) => e.user_email === currentUser.email)?.id : undefined, // replace currentUserID with the actual ID of the current user assigned_to: onlyMine ? bodyshop?.employees?.find((e) => e.user_email === currentUser.email)?.id : undefined,
offset: page ? (page - 1) * pageLimit : 0, offset: page ? (page - 1) * pageLimit : 0,
limit: pageLimit, limit: pageLimit,
order: [ order: [

View File

@@ -1,5 +1,4 @@
import { Col, Form, Input, Row, Select, Switch } from "antd"; import { Col, Form, Input, Row, Select, Switch } from "antd";
import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
@@ -8,6 +7,7 @@ import { connect } from "react-redux";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component.jsx"; import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component.jsx";
import JobSearchSelectComponent from "../job-search-select/job-search-select.component.jsx"; import JobSearchSelectComponent from "../job-search-select/job-search-select.component.jsx";
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 { Link } from "react-router-dom";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -42,7 +42,7 @@ export function TaskUpsertModalComponent({
]; ];
const generatePresets = (job) => { const generatePresets = (job) => {
if (!job || !selectedJobDetails) return datePickerPresets; // return default presets if no job selected if (!job || !selectedJobDetails) return datePickerPresets;
const relativePresets = []; const relativePresets = [];
if (selectedJobDetails?.scheduled_completion) { if (selectedJobDetails?.scheduled_completion) {
@@ -97,13 +97,8 @@ export function TaskUpsertModalComponent({
}); });
}; };
/**
* Change the selected job id
* @param jobId
*/
const changeJobId = (jobId) => { const changeJobId = (jobId) => {
setSelectedJobId(jobId || null); setSelectedJobId(jobId || null);
// Reset the form fields when selectedJobId changes
clearRelations(); clearRelations();
}; };
@@ -163,6 +158,13 @@ export function TaskUpsertModalComponent({
required: true required: true
} }
]} ]}
extra={
existingTask && selectedJobId ? (
<div style={{ textAlign: "right" }}>
<Link to={`/manage/jobs/${selectedJobId}`}>{t("tasks.labels.go_to_job")}</Link>
</div>
) : null
}
> >
<JobSearchSelectComponent <JobSearchSelectComponent
placeholder={t("tasks.placeholders.jobid")} placeholder={t("tasks.placeholders.jobid")}
@@ -203,7 +205,18 @@ export function TaskUpsertModalComponent({
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={8}> <Col span={8}>
<Form.Item label={t("tasks.fields.billid")} name="billid"> <Form.Item
label={t("tasks.fields.billid")}
name="billid"
extra={
form.getFieldValue("billid") ? (
<Link to={`/manage/bills?billid=${form.getFieldValue("billid")}`}>
{t("tasks.links.go_to_bill")} (
{selectedJobDetails?.bills?.find((bill) => bill.id === form.getFieldValue("billid"))?.invoice_number})
</Link>
) : null
}
>
<Select <Select
allowClear allowClear
placeholder={t("tasks.placeholders.billid")} placeholder={t("tasks.placeholders.billid")}

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery } from "@apollo/client"; import { useMutation, useQuery } from "@apollo/client";
import { Form, Modal } from "antd"; import { Form, Modal } from "antd";
import React, { useEffect, useState } from "react"; import { 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";

View File

@@ -107,7 +107,7 @@ export default function TimeTicketCalculatorComponent({
open={visible} open={visible}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
placement="right" placement="right"
destroyTooltipOnHide destroyOnHidden
> >
<Button onClick={(e) => e.preventDefault()}> <Button onClick={(e) => e.preventDefault()}>
<Space> <Space>

View File

@@ -1,4 +1,3 @@
// SocketProvider.js
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import SocketIO from "socket.io-client"; import SocketIO from "socket.io-client";
import { auth } from "../../firebase/firebase.utils"; import { auth } from "../../firebase/firebase.utils";
@@ -16,7 +15,9 @@ import {
import { useMutation } from "@apollo/client"; import { useMutation } from "@apollo/client";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { SocketContext, INITIAL_NOTIFICATIONS } from "./useSocket.js"; import { INITIAL_NOTIFICATIONS, SocketContext } from "./useSocket.js";
const LIMIT = INITIAL_NOTIFICATIONS;
/** /**
* Socket Provider - Scenario Notifications / Web Socket related items * Socket Provider - Scenario Notifications / Web Socket related items
@@ -157,7 +158,10 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
auth: { token, bodyshopId: bodyshop.id }, auth: { token, bodyshopId: bodyshop.id },
reconnectionAttempts: Infinity, reconnectionAttempts: Infinity,
reconnectionDelay: 2000, reconnectionDelay: 2000,
reconnectionDelayMax: 10000 reconnectionDelayMax: 60000
// randomizationFactor: 0.5,
// transports: ["websocket", "polling"], // Add this to prefer WebSocket with polling fallback
// rememberUpgrade: true
}); });
socketRef.current = socketInstance; socketRef.current = socketInstance;
@@ -167,6 +171,82 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
switch (message.type) { switch (message.type) {
case "alert-update": case "alert-update":
store.dispatch(addAlerts(message.payload)); store.dispatch(addAlerts(message.payload));
break;
case "task-created":
case "task-updated":
case "task-deleted":
const payload = message.payload;
const assignedToId = bodyshop?.employees?.find((e) => e.user_email === currentUser?.email)?.id;
if (!assignedToId || payload.assigned_to !== assignedToId) return;
const dueVars = {
bodyshop: bodyshop?.id,
assigned_to: assignedToId,
order: [{ due_date: "asc" }, { created_at: "desc" }]
};
const noDueVars = {
bodyshop: bodyshop?.id,
assigned_to: assignedToId,
order: [{ created_at: "desc" }],
limit: LIMIT,
offset: 0
};
const whereBase = {
bodyshopid: { _eq: bodyshop?.id },
assigned_to: { _eq: assignedToId },
deleted: { _eq: false },
completed: { _eq: false }
};
const whereDue = { ...whereBase, due_date: { _is_null: false } };
const whereNoDue = { ...whereBase, due_date: { _is_null: true } };
// Helper to invalidate a cache entry
const invalidateCache = (fieldName, args) => {
try {
client.cache.evict({
id: "ROOT_QUERY",
fieldName,
args
});
} catch (error) {
console.error("Error invalidating cache:", error);
}
};
// Invalidate lists and aggregates based on event type
if (message.type === "task-deleted" || message.type === "task-updated") {
// Invalidate both lists and no due aggregate for deletes and updates
invalidateCache("tasks", { where: whereDue, order_by: dueVars.order });
invalidateCache("tasks", {
where: whereNoDue,
order_by: noDueVars.order,
limit: noDueVars.limit,
offset: noDueVars.offset
});
invalidateCache("tasks_aggregate", { where: whereNoDue });
} else if (message.type === "task-created") {
// For creates, invalidate the target list and no due aggregate if applicable
const hasDue = !!payload.due_date;
if (hasDue) {
invalidateCache("tasks", { where: whereDue, order_by: dueVars.order });
} else {
invalidateCache("tasks", {
where: whereNoDue,
order_by: noDueVars.order,
limit: noDueVars.limit,
offset: noDueVars.offset
});
invalidateCache("tasks_aggregate", { where: whereNoDue });
}
}
// Always invalidate the total count for all events (handles creates, deletes, updates including completions)
invalidateCache("tasks_aggregate", { where: whereBase });
// Garbage collect after evictions
client.cache.gc();
break; break;
default: default:
break; break;

View File

@@ -3,6 +3,8 @@ import { createContext, useContext } from "react";
const SocketContext = createContext(null); const SocketContext = createContext(null);
const INITIAL_NOTIFICATIONS = 10; const INITIAL_NOTIFICATIONS = 10;
const INITIAL_TASKS = 5;
const TASKS_CENTER_POLL_INTERVAL = 60 * 60 * 1000; // 1 hour in milliseconds
const useSocket = () => { const useSocket = () => {
const context = useContext(SocketContext); const context = useContext(SocketContext);
@@ -10,4 +12,4 @@ const useSocket = () => {
return context; return context;
}; };
export { SocketContext, INITIAL_NOTIFICATIONS, useSocket }; export { SocketContext, INITIAL_NOTIFICATIONS, INITIAL_TASKS, TASKS_CENTER_POLL_INTERVAL, useSocket };

View File

@@ -31,6 +31,8 @@ export const QUERY_ALL_ACTIVE_APPOINTMENTS = gql`
color color
note note
job { job {
scheduled_in
scheduled_completion
alt_transport alt_transport
ro_number ro_number
ownr_ln ownr_ln

View File

@@ -312,7 +312,6 @@ export const QUERY_INTAKE_CHECKLIST = gql`
intakechecklist intakechecklist
status status
owner { owner {
allow_text_message
id id
} }
labhrs: joblines_aggregate(where: { _and: [{ mod_lbr_ty: { _neq: "LAR" } }, { removed: { _eq: false } }] }) { labhrs: joblines_aggregate(where: { _and: [{ mod_lbr_ty: { _neq: "LAR" } }, { removed: { _eq: false } }] }) {

View File

@@ -43,6 +43,7 @@ export const CONVERSATION_SUBSCRIPTION_BY_PK = gql`
id id
status status
text text
is_system
isoutbound isoutbound
image image
image_path image_path
@@ -77,6 +78,7 @@ export const GET_CONVERSATION_DETAILS = gql`
id id
status status
text text
is_system
isoutbound isoutbound
image image
image_path image_path

View File

@@ -874,7 +874,6 @@ export const QUERY_JOB_CARD_DETAILS = gql`
} }
owner { owner {
id id
allow_text_message
preferred_contact preferred_contact
tax_number tax_number
} }
@@ -2071,7 +2070,6 @@ export const QUERY_JOB_CHECKLISTS = gql`
production_vars production_vars
owner { owner {
id id
allow_text_message
} }
bodyshop { bodyshop {
id id
@@ -2428,7 +2426,6 @@ export const QUERY_PARTS_QUEUE_CARD_DETAILS = gql`
ownr_ph2 ownr_ph2
owner { owner {
id id
allow_text_message
preferred_contact preferred_contact
tax_number tax_number
} }

View File

@@ -49,7 +49,6 @@ export const QUERY_OWNER_BY_ID = gql`
owners_by_pk(id: $id) { owners_by_pk(id: $id) {
id id
accountingid accountingid
allow_text_message
ownr_addr1 ownr_addr1
ownr_addr2 ownr_addr2
ownr_co_nm ownr_co_nm
@@ -104,7 +103,6 @@ export const QUERY_ALL_OWNERS = gql`
query QUERY_ALL_OWNERS { query QUERY_ALL_OWNERS {
owners { owners {
id id
allow_text_message
created_at created_at
ownr_addr1 ownr_addr1
ownr_addr2 ownr_addr2
@@ -129,7 +127,6 @@ export const QUERY_ALL_OWNERS_PAGINATED = gql`
query QUERY_ALL_OWNERS_PAGINATED($search: String, $offset: Int, $limit: Int, $order: [owners_order_by!]!) { query QUERY_ALL_OWNERS_PAGINATED($search: String, $offset: Int, $limit: Int, $order: [owners_order_by!]!) {
search_owners(args: { search: $search }, offset: $offset, limit: $limit, order_by: $order) { search_owners(args: { search: $search }, offset: $offset, limit: $limit, order_by: $order) {
id id
allow_text_message
created_at created_at
ownr_addr1 ownr_addr1
ownr_addr2 ownr_addr2

View File

@@ -0,0 +1,64 @@
import { gql } from "@apollo/client";
export const GET_PHONE_NUMBER_OPT_OUT = gql`
query GET_PHONE_NUMBER_OPT_OUT($bodyshopid: uuid!, $phone_number: String!) {
phone_number_opt_out(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _eq: $phone_number } }) {
id
bodyshopid
phone_number
created_at
updated_at
}
}
`;
export const GET_PHONE_NUMBER_OPT_OUTS = gql`
query GET_PHONE_NUMBER_OPT_OUTS($bodyshopid: uuid!, $search: String) {
phone_number_opt_out(
where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _ilike: $search } }
order_by: [{ phone_number: asc }, { updated_at: desc }]
) {
id
bodyshopid
phone_number
created_at
updated_at
}
}
`;
export const GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS = gql`
query GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS($bodyshopid: uuid!, $phone_numbers: [String!]) {
phone_number_opt_out(
where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _in: $phone_numbers } }
) {
id
bodyshopid
phone_number
created_at
updated_at
}
}
`;
export const SEARCH_OWNERS_BY_PHONE_NUMBERS = gql`
query SEARCH_OWNERS_BY_PHONE_NUMBERS($bodyshopid: uuid!, $phone_numbers: [String!]) {
owners(
where: {
shopid: { _eq: $bodyshopid },
_or: [
{ ownr_ph1: { _in: $phone_numbers } },
{ ownr_ph2: { _in: $phone_numbers } }
]
}
) {
id
ownr_fn
ownr_ln
ownr_co_nm
ownr_ph1
ownr_ph2
__typename
}
}
`;

View File

@@ -67,6 +67,105 @@ export const PARTIAL_TASK_FIELDS = gql`
} }
`; `;
export const PARTIAL_TASK_CENTER_FIELDS = gql`
fragment PartialTaskFields on tasks {
id
title
description
due_date
priority
jobid
job {
ro_number
}
joblineid
partsorderid
billid
remind_at
created_at
assigned_to
bodyshopid
deleted
completed
}
`;
export const QUERY_TASKS_WITH_DUE_DATES = gql`
${PARTIAL_TASK_CENTER_FIELDS}
query QUERY_TASKS_WITH_DUE_DATES($bodyshop: uuid!, $assigned_to: uuid!, $order: [tasks_order_by!]!) {
tasks(
where: {
bodyshopid: { _eq: $bodyshop }
assigned_to: { _eq: $assigned_to }
deleted: { _eq: false }
completed: { _eq: false }
due_date: { _is_null: false }
}
order_by: $order
) {
...PartialTaskFields
}
}
`;
export const QUERY_TASKS_NO_DUE_DATE_PAGINATED = gql`
${PARTIAL_TASK_CENTER_FIELDS}
query QUERY_TASKS_NO_DUE_DATE_PAGINATED(
$bodyshop: uuid!
$assigned_to: uuid!
$order: [tasks_order_by!]!
$limit: Int!
$offset: Int!
) {
tasks(
where: {
bodyshopid: { _eq: $bodyshop }
assigned_to: { _eq: $assigned_to }
deleted: { _eq: false }
completed: { _eq: false }
due_date: { _is_null: true }
}
order_by: $order
limit: $limit
offset: $offset
) {
...PartialTaskFields
}
tasks_aggregate(
where: {
bodyshopid: { _eq: $bodyshop }
assigned_to: { _eq: $assigned_to }
deleted: { _eq: false }
completed: { _eq: false }
due_date: { _is_null: true }
}
) {
aggregate {
count
}
}
}
`;
/**
* Query to get the count of my tasks
* @type {DocumentNode}
*/
export const QUERY_MY_TASKS_COUNT = gql`
query QUERY_MY_TASKS_COUNT($assigned_to: uuid!, $bodyshopid: uuid!) {
tasks_aggregate(
where: {
assigned_to: { _eq: $assigned_to }
bodyshopid: { _eq: $bodyshopid }
completed: { _eq: false }
deleted: { _eq: false }
}
) {
aggregate {
count
}
}
}
`;
export const QUERY_GET_TASK_BY_ID = gql` export const QUERY_GET_TASK_BY_ID = gql`
${PARTIAL_TASK_FIELDS} ${PARTIAL_TASK_FIELDS}
query QUERY_GET_TASK_BY_ID($id: uuid!) { query QUERY_GET_TASK_BY_ID($id: uuid!) {
@@ -287,6 +386,43 @@ export const QUERY_JOB_TASKS_PAGINATED = gql`
} }
`; `;
export const QUERY_MY_ACTIVE_TASKS_PAGINATED = gql`
${PARTIAL_TASK_FIELDS}
query QUERY_MY_ACTIVE_TASKS_PAGINATED(
$assigned_to: uuid!
$bodyshop: uuid!
$offset: Int
$limit: Int
$order: [tasks_order_by!]!
) {
tasks(
offset: $offset
limit: $limit
order_by: $order
where: {
assigned_to: { _eq: $assigned_to }
bodyshopid: { _eq: $bodyshop }
deleted: { _eq: false }
completed: { _eq: false }
}
) {
...TaskFields
}
tasks_aggregate(
where: {
assigned_to: { _eq: $assigned_to }
bodyshopid: { _eq: $bodyshop }
deleted: { _eq: false }
completed: { _eq: false }
}
) {
aggregate {
count
}
}
}
`;
export const QUERY_MY_TASKS_PAGINATED = gql` export const QUERY_MY_TASKS_PAGINATED = gql`
${PARTIAL_TASK_FIELDS} ${PARTIAL_TASK_FIELDS}
query QUERY_MY_TASKS_PAGINATED( query QUERY_MY_TASKS_PAGINATED(

View File

@@ -114,7 +114,6 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
if (!!!job.ownerid) { if (!!!job.ownerid) {
ownerData = job.owner.data; ownerData = job.owner.data;
ownerData.shopid = bodyshop.id; ownerData.shopid = bodyshop.id;
delete ownerData.allow_text_message;
delete ownerData.preferred_contact; delete ownerData.preferred_contact;
delete job.ownerid; delete job.ownerid;
} else { } else {

View File

@@ -14,7 +14,7 @@ import { Badge, Button, Divider, Form, Space, Tabs } from "antd";
import Axios from "axios"; import Axios from "axios";
import _ from "lodash"; import _ from "lodash";
import queryString from "query-string"; import queryString from "query-string";
import React, { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FaHardHat, FaRegStickyNote, FaShieldAlt, FaTasks } from "react-icons/fa"; import { FaHardHat, FaRegStickyNote, FaShieldAlt, FaTasks } from "react-icons/fa";
import { connect } from "react-redux"; import { connect } from "react-redux";
@@ -28,6 +28,7 @@ import JobLineUpsertModalContainer from "../../components/job-lines-upsert-modal
import JobProfileDataWarning from "../../components/job-profile-data-warning/job-profile-data-warning.component"; import JobProfileDataWarning from "../../components/job-profile-data-warning/job-profile-data-warning.component";
import JobReconciliationModal from "../../components/job-reconciliation-modal/job-reconciliation.modal.container"; import JobReconciliationModal from "../../components/job-reconciliation-modal/job-reconciliation.modal.container";
import JobSyncButton from "../../components/job-sync-button/job-sync-button.component"; import JobSyncButton from "../../components/job-sync-button/job-sync-button.component";
import JobWatcherToggleContainer from "../../components/job-watcher-toggle/job-watcher-toggle.container.jsx";
import JobsChangeStatus from "../../components/jobs-change-status/jobs-change-status.component"; import JobsChangeStatus from "../../components/jobs-change-status/jobs-change-status.component";
import JobsConvertButton from "../../components/jobs-convert-button/jobs-convert-button.component"; import JobsConvertButton from "../../components/jobs-convert-button/jobs-convert-button.component";
import JobsDetailDatesComponent from "../../components/jobs-detail-dates/jobs-detail-dates.component"; import JobsDetailDatesComponent from "../../components/jobs-detail-dates/jobs-detail-dates.component";
@@ -45,6 +46,8 @@ import LockWrapperComponent from "../../components/lock-wrapper/lock-wrapper.com
import NoteUpsertModalComponent from "../../components/note-upsert-modal/note-upsert-modal.container"; import NoteUpsertModalComponent from "../../components/note-upsert-modal/note-upsert-modal.container";
import ScheduleJobModalContainer from "../../components/schedule-job-modal/schedule-job-modal.container"; import ScheduleJobModalContainer from "../../components/schedule-job-modal/schedule-job-modal.container";
import TaskListContainer from "../../components/task-list/task-list.container.jsx"; import TaskListContainer from "../../components/task-list/task-list.container.jsx";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { QUERY_PARTS_BILLS_BY_JOBID } from "../../graphql/bills.queries.js"; import { QUERY_PARTS_BILLS_BY_JOBID } from "../../graphql/bills.queries.js";
import { QUERY_JOB_TASKS_PAGINATED } from "../../graphql/tasks.queries.js"; import { QUERY_JOB_TASKS_PAGINATED } from "../../graphql/tasks.queries.js";
import { insertAuditTrail } from "../../redux/application/application.actions"; import { insertAuditTrail } from "../../redux/application/application.actions";
@@ -55,9 +58,6 @@ import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { DateTimeFormat } from "../../utils/DateFormatter"; import { DateTimeFormat } from "../../utils/DateFormatter";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import UndefinedToNull from "../../utils/undefinedtonull"; import UndefinedToNull from "../../utils/undefinedtonull";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import JobWatcherToggleContainer from "../../components/job-watcher-toggle/job-watcher-toggle.container.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -322,11 +322,11 @@ export function JobsDetailPage({
> >
<PageHeader <PageHeader
// onBack={() => window.history.back()} // onBack={() => window.history.back()}
id="job-detail-header"
title={ title={
<Space> <Space>
{scenarioNotificationsOn && <JobWatcherToggleContainer job={job} />} {scenarioNotificationsOn && <JobWatcherToggleContainer job={job} />}
{job.ro_number || t("general.labels.na")} <span id="job-ro_number">{job.ro_number || t("general.labels.na")}</span>
</Space> </Space>
} }
extra={menuExtra} extra={menuExtra}

View File

@@ -1,7 +1,7 @@
import Icon, { FieldTimeOutlined } from "@ant-design/icons"; import Icon, { FieldTimeOutlined } from "@ant-design/icons";
import { Card, Tabs } from "antd"; import { Card, Tabs } from "antd";
import queryString from "query-string"; import queryString from "query-string";
import React, { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FaShieldAlt } from "react-icons/fa"; import { FaShieldAlt } from "react-icons/fa";
import { connect } from "react-redux"; import { connect } from "react-redux";
@@ -78,7 +78,7 @@ export function ScoreboardContainer({ setBreadcrumbs, setSelectedHeader }) {
<RbacWrapper action="scoreboard:view"> <RbacWrapper action="scoreboard:view">
<Tabs <Tabs
activeKey={tab || "sb"} activeKey={tab || "sb"}
destroyInactiveTabPane destroyOnHidden
onChange={(key) => { onChange={(key) => {
searchParams.tab = key; searchParams.tab = key;
history({ history({

View File

@@ -10,10 +10,10 @@ import ShopCsiConfig from "../../components/shop-csi-config/shop-csi-config.comp
import ShopEmployeesContainer from "../../components/shop-employees/shop-employees.container"; import ShopEmployeesContainer from "../../components/shop-employees/shop-employees.container";
import ShopInfoContainer from "../../components/shop-info/shop-info.container"; import ShopInfoContainer from "../../components/shop-info/shop-info.container";
import ShopInfoUsersComponent from "../../components/shop-users/shop-users.component"; import ShopInfoUsersComponent from "../../components/shop-users/shop-users.component";
import ShopInfoConsentComponent from "../../components/shop-info/shop-info.consent.component";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
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 { HasFeatureAccess } from "../../components/feature-wrapper/feature-wrapper.component"; import { HasFeatureAccess } from "../../components/feature-wrapper/feature-wrapper.component";
import ShopTeamsContainer from "../../components/shop-teams/shop-teams.container"; import ShopTeamsContainer from "../../components/shop-teams/shop-teams.container";
@@ -91,6 +91,16 @@ export function ShopPage({ bodyshop, setSelectedHeader, setBreadcrumbs }) {
children: <ShopCsiConfig /> children: <ShopCsiConfig />
}); });
} }
if (bodyshop.messagingservicesid) {
// Add Consent Settings tab
items.push({
key: "consent",
label: t("bodyshop.labels.consent_settings"),
children: <ShopInfoConsentComponent bodyshop={bodyshop} />
});
}
return ( return (
<RbacWrapper action="shop:config"> <RbacWrapper action="shop:config">
<Tabs activeKey={search.tab} onChange={(key) => history({ search: `?tab=${key}` })} items={items} /> <Tabs activeKey={search.tab} onChange={(key) => history({ search: `?tab=${key}` })} items={items} />

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import TasksPageComponent from "./tasks.page.component"; import TasksPageComponent from "./tasks.page.component";
import queryString from "query-string"; import queryString from "query-string";

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import TasksPageComponent from "./tasks.page.component"; import TasksPageComponent from "./tasks.page.component";

View File

@@ -1,4 +1,3 @@
import React from "react";
import TaskListContainer from "../../components/task-list/task-list.container.jsx"; import TaskListContainer from "../../components/task-list/task-list.container.jsx";
import { QUERY_ALL_TASKS_PAGINATED, QUERY_MY_TASKS_PAGINATED } from "../../graphql/tasks.queries.js"; import { QUERY_ALL_TASKS_PAGINATED, QUERY_MY_TASKS_PAGINATED } from "../../graphql/tasks.queries.js";
import taskPageTypes from "./taskPageTypes.jsx"; import taskPageTypes from "./taskPageTypes.jsx";
@@ -10,7 +9,7 @@ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser, currentUser: selectCurrentUser,
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({}); const mapDispatchToProps = () => ({});
export default connect(mapStateToProps, mapDispatchToProps)(TasksPageComponent); export default connect(mapStateToProps, mapDispatchToProps)(TasksPageComponent);

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