Compare commits

...

201 Commits

Author SHA1 Message Date
Dave Richer
045f36e294 Merged in release/2026-06-05 (pull request #3288)
IO-3722 Remove delivery date for bypass vehicles.
2026-05-28 19:23:09 +00:00
Dave Richer
c7c6dfcd7d Merged in feature/IO-3722-disable-contact-fortellis (pull request #3287)
IO-3722 Remove delivery date for bypass vehicles.
2026-05-28 19:22:53 +00:00
Patrick Fic
c1c0b35c8f IO-3722 Remove delivery date for bypass vehicles. 2026-05-28 11:32:23 -07:00
Dave Richer
c024fdd57b Merged in release/2026-06-05 (pull request #3285)
Release/2026 06 05
2026-05-28 16:56:04 +00:00
Dave Richer
a4ccacf83a Merged in feature/IO-3722-disable-contact-fortellis (pull request #3284)
IO-3722 Remove customer lookup by Vehicle Owner.
2026-05-28 16:55:39 +00:00
Patrick Fic
aa3b303fe9 IO-3722 Remove customer lookup by Vehicle Owner. 2026-05-28 09:53:40 -07:00
Patrick Fic
fdaf50d778 Merged in feature/IO-3722-disable-contact-fortellis (pull request #3282)
Feature/IO-3722 disable contact fortellis
2026-05-27 21:48:17 +00:00
Patrick Fic
468ed23f73 IO-3722 Fix undefined customer ref. 2026-05-27 14:18:31 -07:00
Patrick Fic
322ebd3bc7 Resolve inversed if statement. 2026-05-27 12:46:09 -07:00
Patrick Fic
b887cfed01 Merged in feature/IO-3722-disable-contact-fortellis (pull request #3278)
IO-3722 Add additional await.
2026-05-27 19:41:41 +00:00
Patrick Fic
0f800c5a4c IO-3722 Add additional await. 2026-05-27 12:40:41 -07:00
Dave Richer
6cce92b0fd Merged in release/2026-06-05 (pull request #3276)
IO-3722 Disable contact API calls for Fortellis.
2026-05-27 18:29:33 +00:00
Dave Richer
60ab04cb38 Merged in feature/IO-3722-disable-contact-fortellis (pull request #3275)
IO-3722 Disable contact API calls for Fortellis.
2026-05-27 18:29:14 +00:00
Patrick FIc
345a470731 IO-3722 Disable contact API calls for Fortellis. 2026-05-27 10:31:33 -07:00
Dave Richer
0025e113c6 Merged in release/2026-06-05 (pull request #3273)
feature/IO-3541-Parts-Dispatch-Return-Data - Add Refetech queries to keep data in sync
2026-05-26 16:25:53 +00:00
Dave Richer
dc435b2bb0 Merged in feature/IO-3541-Parts-Dispatch-Return-Data (pull request #3272)
feature/IO-3541-Parts-Dispatch-Return-Data - Add Refetech queries to keep data in sync
2026-05-26 16:25:23 +00:00
Dave
fd72d244e7 feature/IO-3541-Parts-Dispatch-Return-Data - Add Refetech queries to keep data in sync 2026-05-26 12:24:56 -04:00
Dave Richer
87bb472271 Merged in release/2026-06-05 (pull request #3271)
feature/IO-2960-Employee-Email-Info - Fix
2026-05-26 16:18:15 +00:00
Dave Richer
825959880e Merged in feature/IO-2960-Employee-Email-Info (pull request #3270)
feature/IO-2960-Employee-Email-Info - Fix
2026-05-26 16:17:55 +00:00
Dave
c40fea0ec9 feature/IO-2960-Employee-Email-Info - Fix 2026-05-26 12:17:25 -04:00
Dave Richer
ebdf427b58 Merged in release/2026-06-05 (pull request #3269)
feature/IO-3567-New-Job-Line-Tab - Fix
2026-05-26 16:06:29 +00:00
Dave Richer
b3fdd68276 Merged in feature/IO-3567-New-Job-Line-Tab (pull request #3268)
feature/IO-3567-New-Job-Line-Tab - Fix
2026-05-26 16:06:02 +00:00
Dave
30e5027c8c feature/IO-3567-New-Job-Line-Tab - Fix 2026-05-26 12:05:31 -04:00
Dave Richer
3e63c58b9b Merged in release/2026-06-05 (pull request #3267)
release/2026-06-05 - Esignture Banner
2026-05-26 15:49:49 +00:00
Dave
938cef1f6b release/2026-06-05 - Esignture Banner 2026-05-26 11:49:08 -04:00
Dave Richer
7e2df3e341 Merged in release/2026-06-05 (pull request #3266)
release/2026-06-05 - Fix Documenso
2026-05-25 20:43:05 +00:00
Dave
45d095a7a3 release/2026-06-05 - Fix Documenso 2026-05-25 16:42:23 -04:00
Dave Richer
709b6ef1d6 Merged in release/2026-06-05 (pull request #3265)
release/2026-06-05 - Package updates, Esig Fixes, Ant tweak for upgrade
2026-05-25 19:52:15 +00:00
Dave
4e98df6694 release/2026-06-05 - Package updates, Esig Fixes, Ant tweak for upgrade 2026-05-25 15:51:37 -04:00
Dave Richer
b920bb4437 Merged in release/2026-06-05 (pull request #3264)
Release/2026 06 05
2026-05-25 19:06:02 +00:00
Dave Richer
e36a110e81 Merged in feature/IO-3713-Esign-Modal-UI (pull request #3263)
Feature/IO-3713 Esign Modal UI
2026-05-25 19:05:41 +00:00
Dave
719d1b6479 Merge remote-tracking branch 'origin/release/2026-06-05' into feature/IO-3713-Esign-Modal-UI 2026-05-25 15:04:55 -04:00
Dave
29ded5efbf feature/IO-3713-Esign-Modal-UI - Fix 2026-05-25 15:01:46 -04:00
Dave
551e0f0592 feature/IO-3713-Esign-Modal-UI - Fix 2026-05-25 15:00:05 -04:00
Dave Richer
4d299bb226 Merged in release/2026-06-05 (pull request #3262)
feature/IO-3701-Harness-Replacement - Implement
2026-05-25 15:44:29 +00:00
Dave Richer
ae9b68a0bc Merged in feature/IO-3701-Harness-Replacement (pull request #3261)
feature/IO-3701-Harness-Replacement - Implement
2026-05-25 15:44:04 +00:00
Dave
cf8df89e30 Merge remote-tracking branch 'origin/release/2026-06-05' into feature/IO-3701-Harness-Replacement 2026-05-25 11:42:54 -04:00
Dave
bfd6cc83af Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3701-Harness-Replacement 2026-05-25 11:29:15 -04:00
Dave Richer
99b65e8186 Merged in release/2026-06-05 (pull request #3260)
feature/IO-3714-Esignature-Lock - Add Lock to Esignatures
2026-05-25 15:27:36 +00:00
Dave Richer
f8fd2ee64c Merged in feature/IO-3714-Esignature-Lock (pull request #3259)
feature/IO-3714-Esignature-Lock - Add Lock to Esignatures
2026-05-25 15:26:49 +00:00
Dave
8240ea9a64 feature/IO-3714-Esignature-Lock - Add Lock to Esignatures 2026-05-25 11:24:55 -04:00
Dave Richer
ebde2f1581 Merged in release/2026-05-22 (pull request #3257)
Release/2026 05 22
2026-05-25 12:45:19 +00:00
Dave
85b3b88538 Merge remote-tracking branch 'origin/release/2026-05-22' into feature/IO-3701-Harness-Replacement 2026-05-21 13:44:38 -04:00
Dave Richer
426283ffee Merged in release/2026-05-22 (pull request #3256)
IO-3710 Visual Board Vehicle Color
2026-05-20 23:57:48 +00:00
Allan Carr
a45808eb94 Merged in feature/IO-3710-Visual-Board-Vehicle-Color (pull request #3255)
IO-3710 Visual Board Vehicle Color

Approved-by: Dave Richer
2026-05-20 23:57:28 +00:00
Allan Carr
a2389b1f26 IO-3710 Visual Board Vehicle Color
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-20 13:42:35 -07:00
Dave Richer
4fc86ccaa3 Merged in release/2026-05-22 (pull request #3254)
release/2026-05-22 - Remove uncessary require
2026-05-20 20:12:07 +00:00
Dave
519997a8be Merge remote-tracking branch 'origin/release/2026-05-22' into feature/IO-3701-Harness-Replacement 2026-05-20 14:47:17 -04:00
Dave
ab606a4266 release/2026-05-22 - Remove uncessary require 2026-05-20 14:46:52 -04:00
Dave
c4c36b7fd0 Merge remote-tracking branch 'origin/release/2026-05-22' into feature/IO-3701-Harness-Replacement 2026-05-20 14:42:12 -04:00
Dave
deb2fc28ce feature/IO-3701-Harness-Replacement - Implement 2026-05-20 14:41:24 -04:00
Dave Richer
a67946c5a3 Merged in release/2026-05-22 (pull request #3253)
IO-3712 Disable analytics in client side.
2026-05-20 18:11:11 +00:00
Patrick Fic
da317704c4 Merged in feature/IO-3712-disable-analytics (pull request #3252)
IO-3712 Disable analytics in client side.

Approved-by: Dave Richer
2026-05-20 18:10:39 +00:00
Patrick Fic
771573409f IO-3712 Disable analytics in client side. 2026-05-20 10:48:56 -07:00
Dave Richer
e43923b7a0 Merged in release/2026-05-22 (pull request #3251)
Release/2026 05 22
2026-05-20 16:54:53 +00:00
Allan Carr
cb9ccb7e77 Merged in feature/IO-3707-Deduct-From-Labor-Enhanced-Payrol (pull request #3249)
IO-3707 Deduct from Labor Enhanced Payroll

Approved-by: Dave Richer
2026-05-20 16:54:09 +00:00
Allan Carr
a5d00d562c Merged in feature/IO-3699-Alt-Part-No-Expose (pull request #3250)
Feature/IO-3699 Alt Part No Expose

Approved-by: Dave Richer
2026-05-20 16:53:36 +00:00
Allan Carr
bdeeea0406 Merged in feature/IO-3710-Visual-Board-Vehicle-Color (pull request #3248)
IO-3710 Visual Board - Vehicle Color

Approved-by: Dave Richer
2026-05-20 16:52:10 +00:00
Allan Carr
297d8afa8a IO-3699 Prettier Run
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-19 18:44:36 -07:00
Allan Carr
3a12597c45 IO-3699 Alt Part # Exposed
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-19 18:43:44 -07:00
Allan Carr
72c96f14eb IO-3707 Prettier Run
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-19 18:35:26 -07:00
Allan Carr
de9d47272c IO-3707 Deduct from Labor Enhanced Payroll
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-19 18:34:19 -07:00
Allan Carr
3fd51f0140 IO-3710 Visual Board - Vehicle Color
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-19 16:01:38 -07:00
Dave Richer
e9ef429729 Merged in release/2026-05-22 (pull request #3247)
Release/2026 05 22
2026-05-14 17:56:22 +00:00
Dave
84ec68f142 release/2026-05-22 - Remove uncessary require 2026-05-14 11:03:40 -04:00
Patrick Fic
22af37e8f1 Merged in feature/IO-3692-remove-patrick (pull request #3246)
IO-3692 Remove patrick's email from all codebase.

Approved-by: Dave Richer
2026-05-14 15:02:06 +00:00
Patrick FIc
86affddc24 IO-3692 Remove patrick's email from all codebase. 2026-05-13 09:40:05 -07:00
Dave Richer
db01ad9155 Merged in release/2026-05-22 (pull request #3245)
Release/2026 05 22
2026-05-13 16:10:49 +00:00
Dave Richer
57fdffff09 Merged in feature/IO-2433-esignature (pull request #3244)
Feature/IO-2433 esignature
2026-05-13 16:10:17 +00:00
Dave
e74be56681 feature/IO-2433-esignature - Code review fixes 2026-05-13 12:09:18 -04:00
Dave
f5d33a2386 Merge branch 'master-AIO' into feature/IO-2433-esignature 2026-05-13 11:51:12 -04:00
Allan Carr
edc9ba33c5 Merged in hotfix/2026-05-12 (pull request #3243)
IO-3691 Job Totals Issues
2026-05-12 15:56:13 +00:00
Allan Carr
4586f32f38 Merged in feature/IO-3691-Jobs-Totals-Issues (pull request #3242)
IO-3691 Job Totals Issues
2026-05-12 15:46:59 +00:00
Allan Carr
8bf7fbd1f1 Merged in release/2026-05-22 (pull request #3241)
IO-3691 Job Totals Issues
2026-05-12 15:42:36 +00:00
Allan Carr
281e50a43e Merged in feature/IO-3691-Jobs-Totals-Issues (pull request #3240)
IO-3691 Job Totals Issues

Approved-by: Patrick Fic
2026-05-12 15:39:57 +00:00
Allan Carr
7a50f2a2fe IO-3691 Job Totals Issues
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-11 22:11:31 -07:00
Dave Richer
0c83f796db Merged in hotfix/2026-05-11 (pull request #3239)
hotfix/2020hotfix/2026-05-11 - Fix so polling throws error on missing env var (logs error), and does not start poll, vs starting polling and logging error every 10 seconds
2026-05-11 21:27:46 +00:00
Dave Richer
c37037ef21 Merged in release/2026-05-22 (pull request #3238)
hotfix/2020hotfix/2026-05-11 - Fix so polling throws error on missing env var (logs error), and does not start poll, vs starting polling and logging error every 10 seconds
2026-05-11 21:26:40 +00:00
Dave Richer
237c575bab Merged in hotfix/2026-05-11 (pull request #3237)
hotfix/2020hotfix/2026-05-11 - Fix so polling throws error on missing env var (logs error), and does not start poll, vs starting polling and logging error every 10 seconds
2026-05-11 21:25:51 +00:00
Dave
a54e74a27d hotfix/2020hotfix/2026-05-11 - Fix so polling throws error on missing env var (logs error), and does not start poll, vs starting polling and logging error every 10 seconds 2026-05-11 17:24:48 -04:00
Dave
87797c7743 Merge branch 'master-AIO' into feature/IO-2433-esignature 2026-05-11 12:07:59 -04:00
Dave Richer
d227cacd68 Merged in release/2026-05-08 (pull request #3236)
Release/2026 05 08 into master-AIO - IO-3562, IO-3647, IO-3650, IO-3667, IO-3672, IO-3673, IO-3674, IO-3676, IO-3679, IO-3686, IO-3687, IO-3688, IO-3689
2026-05-09 00:54:18 +00:00
Dave Richer
6050aebcd5 Merged in release/2026-05-08 (pull request #3235)
IO-3689 Customer List Restriction
2026-05-08 18:45:38 +00:00
Allan Carr
ef4565d738 Merged in feature/IO-3689-Customer-List-Restriction (pull request #3234)
IO-3689 Customer List Restriction

Approved-by: Dave Richer
2026-05-08 18:45:21 +00:00
Allan Carr
74eeceacca IO-3689 Customer List Restriction
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-08 11:32:49 -07:00
Dave Richer
77d0f5ab38 Merged in release/2026-05-08 (pull request #3233)
feature/IO-3688-Searchable-Referral-Source - Implement (convert button)
2026-05-08 15:43:23 +00:00
Dave Richer
6e566e2f8a Merged in feature/IO-3688-Searchable-Referral-Source (pull request #3232)
feature/IO-3688-Searchable-Referral-Source - Implement (convert button)
2026-05-08 15:43:01 +00:00
Dave
80697a5259 feature/IO-3688-Searchable-Referral-Source - Implement (convert button) 2026-05-08 11:42:19 -04:00
Dave Richer
a0692f8c69 Merged in release/2026-05-08 (pull request #3231)
feature/IO-3688-Searchable-Referral-Source - Implement
2026-05-08 14:42:36 +00:00
Dave Richer
fe8d1f7e95 Merged in feature/IO-3688-Searchable-Referral-Source (pull request #3230)
feature/IO-3688-Searchable-Referral-Source - Implement
2026-05-08 14:41:56 +00:00
Dave
32f3143dca feature/IO-3688-Searchable-Referral-Source - Implement 2026-05-08 10:40:21 -04:00
Dave Richer
4f76aeb06f Merged in release/2026-05-08 (pull request #3229)
feature/IO-3679-Tech-Console-Null-Error - fix
2026-05-07 14:43:08 +00:00
Dave Richer
0ba207a499 Merged in feature/IO-3679-Tech-Console-Null-Error (pull request #3228)
feature/IO-3679-Tech-Console-Null-Error - fix
2026-05-07 14:42:17 +00:00
Dave
f849ea9d0a feature/IO-3679-Tech-Console-Null-Error - fix 2026-05-07 10:41:19 -04:00
Dave Richer
302a42089f Merged in release/2026-05-08 (pull request #3227)
IO-3686 River city enhancements for AR customers and Contact Code
2026-05-07 14:05:14 +00:00
Patrick Fic
e0b113e5d0 Merged in feature/IO-3686-pbs-enhancements (pull request #3224)
IO-3686 River city enhancements for AR customers and Contact Code

Approved-by: Dave Richer
2026-05-07 14:03:48 +00:00
Dave Richer
906265c4b2 Merged in release/2026-05-08 (pull request #3226)
feature/IO-3687-Grey-Scale-Invisible-text - implement
2026-05-06 20:46:31 +00:00
Dave Richer
fc199279d1 Merged in feature/IO-3687-Grey-Scale-Invisible-text (pull request #3225)
feature/IO-3687-Grey-Scale-Invisible-text - implement
2026-05-06 20:46:14 +00:00
Dave
fcba77fe20 feature/IO-3687-Grey-Scale-Invisible-text - implement 2026-05-06 16:45:00 -04:00
Patrick Fic
f294eafde7 IO-3686 River city enhancements for AR customers and Contact Code 2026-05-06 11:57:53 -07:00
Dave Richer
388b042037 Merged in release/2026-05-08 (pull request #3223)
Release/2026 05 08
2026-05-06 15:48:38 +00:00
Dave Richer
e0f55b8e7a Merged in feature/IO-3673-Order-Parts-Receive-Bill-Bug (pull request #3222)
feature/IO-3673-Order-Parts-Receive-Bill-Bug - Fix

Approved-by: Allan Carr
2026-05-06 15:48:10 +00:00
Dave Richer
ef6aee0518 Merged in feature/IO-3676-Order-As-In-House-Quantity (pull request #3221)
feature/IO-3676-Order-As-In-House-Quantity - Fix

Approved-by: Allan Carr
2026-05-06 15:48:00 +00:00
Patrick Fic
d5e643b429 Updated terraform WAF. 2026-05-05 11:14:23 -07:00
Dave
88ae1fb1cc feature/IO-3673-Order-Parts-Receive-Bill-Bug - Fix 2026-05-05 13:56:16 -04:00
Dave
c6af2b34b2 feature/IO-3676-Order-As-In-House-Quantity - Fix 2026-05-05 13:41:50 -04:00
Dave Richer
73eb76a230 Merged in release/2026-05-08 (pull request #3220)
feature/IO-3672-Reynolds-Adjustments-V3 - Expand Export logs for Reynolds
2026-05-05 17:31:56 +00:00
Dave Richer
d51dcc0ef2 Merged in feature/IO-3672-Reynolds-Adjustments-V3 (pull request #3219)
feature/IO-3672-Reynolds-Adjustments-V3 - Expand Export logs for Reynolds
2026-05-05 17:31:36 +00:00
Dave
e6178a613d feature/IO-3672-Reynolds-Adjustments-V3 - Expand Export logs for Reynolds 2026-05-05 13:31:03 -04:00
Dave Richer
d5e9b79f75 Merged in release/2026-05-08 (pull request #3218)
feature/IO-3672-Reynolds-Adjustments-V3 - Hide DMS Posting sheet report in reynolds mode.
2026-05-04 21:09:10 +00:00
Dave Richer
2a69115903 Merged in feature/IO-3672-Reynolds-Adjustments-V3 (pull request #3217)
feature/IO-3672-Reynolds-Adjustments-V3 - Hide DMS Posting sheet report in reynolds mode.
2026-05-04 21:08:41 +00:00
Dave
c8262da440 feature/IO-3672-Reynolds-Adjustments-V3 - Hide DMS Posting sheet report in reynolds mode. 2026-05-04 16:58:06 -04:00
Dave Richer
56d0c009e2 Merged in release/2026-05-08 (pull request #3216)
feature/IO-3672-Reynolds-Adjustments-V3 - Make sure there is never a scenario where a ROGOG does not have a ROLABOR
2026-05-04 20:33:08 +00:00
Dave Richer
1f41a532e2 Merged in feature/IO-3672-Reynolds-Adjustments-V3 (pull request #3215)
feature/IO-3672-Reynolds-Adjustments-V3 - Make sure there is never a scenario where a ROGOG does not have a ROLABOR
2026-05-04 20:32:52 +00:00
Dave
32e67b14b6 feature/IO-3672-Reynolds-Adjustments-V3 - Make sure there is never a scenario where a ROGOG does not have a ROLABOR 2026-05-04 16:31:41 -04:00
Dave Richer
79030f6b36 Merged in release/2026-05-08 (pull request #3214)
feature/IO-3674-Fix-Save-And-New - Fix Save and New so state gets reset on form when starting from a new employee
2026-05-04 20:13:21 +00:00
Dave Richer
d901004751 Merged in feature/IO-3674-Fix-Save-And-New (pull request #3213)
feature/IO-3674-Fix-Save-And-New - Fix Save and New so state gets reset on form when starting from a new employee
2026-05-04 20:12:59 +00:00
Dave
661e019a4d feature/IO-3674-Fix-Save-And-New - Fix Save and New so state gets reset on form when starting from a new employee 2026-05-04 16:11:54 -04:00
Patrick Fic
82021c1edc Updated terraform state after documenso update. 2026-05-01 08:40:44 -07:00
Dave
a6156a70c1 feature/IO-2433-esignature - Add in Notifications 2026-04-30 18:06:32 -04:00
Dave
0014a5335d Merge branch 'master-AIO' into feature/IO-2433-esignature 2026-04-30 12:08:35 -04:00
Dave Richer
5e78cdd8ae Merged in release/2026-05-08 (pull request #3210)
Release/2026 05 08
2026-04-29 16:46:47 +00:00
Allan Carr
cd054fcf33 Merged in feature/IO-3650-Pagination-Corrections (pull request #3208)
IO-3650 Pagination Corrections

Approved-by: Dave Richer
2026-04-29 16:46:02 +00:00
Allan Carr
5ab54433ff Merged in feature/IO-3562-VPB-Exclude-Suspended (pull request #3209)
IO-3562 Visual Production Board Statistics - Exclude Suspended Jobs

Approved-by: Dave Richer
2026-04-29 16:45:31 +00:00
Allan Carr
62c053ed87 Merged in feature/IO-3667-Visual-Production-Title-Color (pull request #3207)
IO-3667 Visual Production Title Color

Approved-by: Dave Richer
2026-04-29 16:44:46 +00:00
Allan Carr
6242e0f309 IO-3667 - Correction for Styling
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 18:10:49 -07:00
Allan Carr
614420d7d2 IO-3562 Visual Production Board Statistics - Exclude Suspended Jobs
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 18:06:11 -07:00
Allan Carr
3113818a91 IO-3650 Pagination Corrections
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 16:44:23 -07:00
Allan Carr
92a3e57205 IO-3667 Visual Production Title Color
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 15:46:40 -07:00
Dave Richer
de6038038a Merged in hotfix/2026-04-28 (pull request #3206)
hotfix/2026-04-28 - Add Label, fix exported
2026-04-28 18:03:13 +00:00
Dave Richer
8f4ac866f1 Merged in release/2026-05-08 (pull request #3205)
hotfix/2026-04-28 - Add Label, fix exported
2026-04-28 17:52:54 +00:00
Dave Richer
8a043767cd Merged in hotfix/2026-04-28 (pull request #3204)
hotfix/2026-04-28 - Add Label, fix exported
2026-04-28 17:52:28 +00:00
Dave
1f8836d9d8 hotfix/2026-04-28 - Add Label, fix exported 2026-04-28 13:51:46 -04:00
Patrick Fic
6ca0ebff5f Add additional translations and cleanup.
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 11:37:25 -07:00
Patrick Fic
a96a1139fa IO-2433 Added LMS document upload.
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 09:59:48 -07:00
Patrick Fic
483da283dc Fetch documenso key dynamically and clean up. Update to 2.9.1 in terraform. 2026-04-23 10:41:34 -07:00
Dave Richer
a267d65425 Merged in hotfix/2026-04-21 (pull request #3203)
Hotfix/2026 04 21
2026-04-22 16:44:49 +00:00
Dave Richer
9ad2a53bec Merged in release/2026-05-08 (pull request #3202)
hotfix/2026-04-21 - fix Parts order comments
2026-04-22 16:44:10 +00:00
Dave Richer
9267e584ff Merged in hotfix/2026-04-21 (pull request #3201)
hotfix/2026-04-21 - fix Parts order comments
2026-04-22 16:43:19 +00:00
Dave
cacda3805a hotfix/2026-04-21 - fix Parts order comments 2026-04-22 12:42:49 -04:00
Dave Richer
6590f8961b Merged in release/2026-05-08 (pull request #3200)
feature/IO-3647-Reynolds-Integration-Phase-2-Optional - Add option to make 'Enhanced Early ROS' optional
2026-04-21 14:52:31 +00:00
Dave Richer
69861af88c Merged in feature/IO-3647-Reynolds-Integration-Phase-2-Optional (pull request #3199)
feature/IO-3647-Reynolds-Integration-Phase-2-Optional - Add option to make 'Enhanced Early ROS' optional
2026-04-21 14:52:00 +00:00
Dave
d7294ebba6 feature/IO-3647-Reynolds-Integration-Phase-2-Optional - Add option to make 'Enhanced Early ROS' optional 2026-04-21 10:51:13 -04:00
Dave Richer
7df71b8f44 Merged in release/2026-05-08 (pull request #3198)
hotfix/2026-04-21 - Fix save dirty state on employees causing prompt, add 'save and new'
2026-04-21 14:29:55 +00:00
Dave Richer
d9270102b1 Merged in hotfix/2026-04-21 (pull request #3197)
hotfix/2026-04-21 - Fix save dirty state on employees causing prompt, add 'save and new'
2026-04-21 14:29:33 +00:00
Dave
af757ee71e hotfix/2026-04-21 - Fix save dirty state on employees causing prompt, add 'save and new' 2026-04-21 10:28:26 -04:00
Patrick Fic
d416780e63 Merge remote-tracking branch 'origin/master-AIO' into feature/IO-2433-esignature 2026-04-20 11:38:35 -07:00
Patrick Fic
b6cbfb8e45 IO-2443 Minor clean up. 2026-04-20 11:37:19 -07:00
Patrick Fic
9c97b30e8e Missed in last commit. 2026-04-20 09:51:34 -07:00
Patrick Fic
cc48448a07 Finalize self hosted with self certificate, resolve webhook issues. 2026-04-20 09:49:48 -07:00
Dave Richer
eb666f2ca1 Merged in hotfix/2026-04-20 (pull request #3195)
hotfix/2026-04-20 - Remove item from Cost centers
2026-04-20 15:57:38 +00:00
Dave Richer
4776b03a21 Merged in release/2026-05-08 (pull request #3194)
hotfix/2026-04-20 - Remove item from Cost centers
2026-04-20 15:41:47 +00:00
Dave Richer
d991e32501 Merged in hotfix/2026-04-20 (pull request #3193)
hotfix/2026-04-20 - Remove item from Cost centers
2026-04-20 15:41:29 +00:00
Dave
2b8990950b hotfix/2026-04-20 - Remove item from Cost centers 2026-04-20 11:40:19 -04:00
Dave Richer
3f2e05befc Merged in release/2026-04-17 (pull request #3192)
Release/2026-04-17 into master-AIO - IO-1366, IO-3624, IO-3638
2026-04-18 02:12:37 +00:00
Dave Richer
06bfdeb449 Merged in feature/IO-3647-Reynolds-Integration-Phase-2 (pull request #3191)
feature/IO-3647-Reynolds-Integration-Phase-2 - Enhance early RO with meaningful amounts.
2026-04-13 15:00:38 +00:00
Dave Richer
20943f74e9 Merged in release/2026-04-17 (pull request #3190)
feature/IO-3647-Reynolds-Integration-Phase-2 - Enhance early RO with meaningful amounts.
2026-04-13 14:41:37 +00:00
Dave Richer
66df286ddb Merged in feature/IO-3647-Reynolds-Integration-Phase-2 (pull request #3189)
feature/IO-3647-Reynolds-Integration-Phase-2 - Enhance early RO with meaningful amounts.
2026-04-13 14:40:15 +00:00
Dave Richer
1b2f9fc027 Merged in hotfix/2026-04-10 (pull request #3188)
hotfix/2026-04-10 - Fix Location Identifier in chatter-api
2026-04-10 15:42:43 +00:00
Dave Richer
4af312854e Merged in release/2026-04-17 (pull request #3187)
hotfix/2026-04-10 - Fix Location Identifier in chatter-api
2026-04-10 15:41:08 +00:00
Dave Richer
1287c7ec36 Merged in hotfix/2026-04-10 (pull request #3186)
hotfix/2026-04-10 - Fix Location Identifier in chatter-api
2026-04-10 15:40:04 +00:00
Dave
fb29fa2caa hotfix/2026-04-10 - Fix Location Identifier in chatter-api 2026-04-10 11:38:56 -04:00
Dave
6bda497d8c feature/IO-3647-Reynolds-Integration-Phase-2 - Enhance early RO with meaningful amounts. 2026-04-09 13:54:48 -04:00
Dave Richer
ff084f6fb8 Merged in release/2026-04-17 (pull request #3185)
feature/IO-3638-Reynolds-OpenSearch - Add Search on DMS id in Reynolds shops
2026-04-09 15:16:48 +00:00
Dave Richer
a018b6dc5a Merged in feature/IO-3638-Reynolds-OpenSearch (pull request #3184)
feature/IO-3638-Reynolds-OpenSearch - Add Search on DMS id in Reynolds shops
2026-04-09 15:16:26 +00:00
Dave
8a4679f86c feature/IO-3638-Reynolds-OpenSearch - Add Search on DMS id in Reynolds shops 2026-04-09 11:14:17 -04:00
Dave Richer
5c9e4517a6 Merged in release/2026-04-17 (pull request #3183)
Release/2026 04 17
2026-04-08 18:03:10 +00:00
Dave Richer
4d558da46a Merged in feature/IO-1366-Re-export-Bill-Audit-Log-codex (pull request #3182)
Feature/IO-1366 Re export Bill Audit Log codex audit
2026-04-08 18:02:39 +00:00
Dave Richer
190217ffce Merged in release/2026-04-17 (pull request #3181)
Release/2026 04 17
2026-04-03 01:56:19 +00:00
Dave Richer
90789e743f Merged in feature/IO-3624-Shop-Config-UX-Refresh (pull request #3180)
Feature/IO-3624 Shop Config UX Refresh
2026-04-03 01:55:24 +00:00
Dave Richer
a4dbc5250e Merged in release/2026-04-03 (pull request #3179)
Release/2026-04-03 - IO-1366, IO-3356, IO-3515, IO-3587, IO-3599, IO-3609, IO-3616, IO-3622, IO-3623, IO-3627, IO-3629, IO-3637
2026-04-03 01:46:11 +00:00
Dave
704543d823 IO-1366 Refine audit trail detail logging 2026-04-02 21:40:59 -04:00
Dave Richer
28dc1d4533 Merged in release/2026-04-03 (pull request #3178)
Merged in feature/IO-3637-DMS-ID-Production-Board-Column (pull request #3175)
2026-04-03 01:37:45 +00:00
Allan Carr
a97e03e0b1 Merged in feature/IO-3637-DMS-ID-Production-Board-Column (pull request #3176)
Feature/IO-3637 DMS ID Production Board Column
2026-04-02 23:07:28 +00:00
Allan Carr
fe848b5de4 IO-1366 Extend Audit Log
Extend for Time Ticket Changes, Manual Lines, Manual Job, Bill Edits

Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-02 11:06:14 -04:00
Dave Richer
e30353cab6 Merged in release/2026-04-03 (pull request #3171)
Release/2026 04 03
2026-03-31 20:19:00 +00:00
Dave Richer
c9b9f67170 Merged in release/2026-04-03 (pull request #3168)
Release/2026 04 03
2026-03-30 19:08:50 +00:00
Patrick Fic
969dd8be8d Add tfvars exclusion. 2026-03-30 11:41:54 -07:00
Patrick Fic
794f64dfba Add custom document signing. 2026-03-30 11:41:24 -07:00
Dave Richer
4a47f543b2 Merged in release/2026-04-03 (pull request #3164)
IO-3629 PostBatchWip Rtn != 0 error
2026-03-30 15:07:33 +00:00
Dave Richer
3b60aa89f1 Merged in release/2026-04-03 (pull request #3160)
Release/2026 04 03
2026-03-27 18:48:51 +00:00
Patrick Fic
220b1c7968 Deployed version of Documenso. 2026-03-26 14:57:09 -07:00
Patrick Fic
7dab60e3bc Initial terraform for Documenso. 2026-03-26 09:15:00 -07:00
Dave Richer
20d2572087 Merged in release/2026-04-03 (pull request #3157)
Release/2026 04 03
2026-03-25 22:36:49 +00:00
Patrick Fic
d4c7298334 Eisgnature Migrations, webhook handling, and clean up. 2026-03-25 15:24:14 -07:00
Dave Richer
ac4c09af60 Merged in release/2026-04-03 (pull request #3154)
Release/2026 04 03
2026-03-24 17:50:29 +00:00
Patrick Fic
e17b57c705 Merge remote-tracking branch 'origin/master-AIO' into feature/IO-2433-esignature 2026-03-23 12:57:38 -07:00
Dave Richer
6a60af9dfe Merged in release/2026-04-03 (pull request #3150)
Release/2026 04 03
2026-03-23 17:05:19 +00:00
Dave Richer
dfb6f02864 Merged in release/2026-04-03 (pull request #3148)
Fix RR
2026-03-20 18:56:28 +00:00
Dave Richer
48bb494e0f Merged in release/2026-04-03 (pull request #3146)
IO-3515 Add shopname to bill ai feedback.
2026-03-20 18:16:04 +00:00
Dave Richer
9b74cba56b Merged in release/2026-04-03 (pull request #3144)
Release/2026 04 03
2026-03-19 22:44:56 +00:00
Dave Richer
6fc8124268 Merged in release/2026-04-03 (pull request #3141)
Release/2026 04 03
2026-03-19 18:47:24 +00:00
Patrick Fic
4abc1a7d0f IO-2433 Resolve webhook issue on esig. 2026-03-16 16:03:37 -07:00
Patrick Fic
255d761210 IO-2433 Add esignature flag to JSR call. 2026-03-16 11:10:52 -07:00
Patrick Fic
2a5e5d2462 IO-2433 Resolve missing esig file errors from JSR. 2026-03-16 09:04:52 -07:00
Allan Carr
6ef56f97c0 IO-2433 Missing Translation
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-12 16:58:59 -07:00
Patrick Fic
97d8047a3d Update casing for esign route. 2026-03-05 15:56:13 -08:00
Patrick Fic
16220d0a27 Merge branch 'master-AIO' into feature/IO-2433-esignature 2026-03-04 15:01:25 -08:00
Patrick Fic
51fba24a3d IO-2433 Delete on cancel, improved styling. 2026-02-27 16:03:27 -08:00
Patrick Fic
52f43a600c IO-2433 Basic completion webhook, S3 upload, audit trail. 2026-02-27 15:44:23 -08:00
Patrick Fic
e25174ff97 IO-2433 Basic embedded authoring. 2026-02-27 13:15:10 -08:00
221 changed files with 26594 additions and 5144 deletions

View File

@@ -7,6 +7,7 @@ _reference
client client
redis/dockerdata redis/dockerdata
hasura hasura
harness-feature-flags-export
node_modules node_modules
# Files to exclude # Files to exclude
.ebignore .ebignore

View File

@@ -7,6 +7,7 @@
/client /client
/firebase /firebase
/hasura /hasura
/harness-feature-flags-export
/jsreport /jsreport
/node_modules /node_modules
.env.local .env.local

9
.gitignore vendored
View File

@@ -17,6 +17,9 @@ jsreport/auth-server/node_modules
client/coverage client/coverage
admin/coverage admin/coverage
# Generated Harness/Split feature flag export artifacts
/harness-feature-flags-export/
# production # production
/build /build
client/build client/build
@@ -149,3 +152,9 @@ docker_data
/COPILOT.md /COPILOT.md
/.github/copilot-instructions.md /.github/copilot-instructions.md
/GEMINI.md /GEMINI.md
/_reference/select-component-test-plan.md
.terraform
terraform.tfvars
terraform.exe

1297
_reference/feature-flags.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

3196
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,61 +8,61 @@
"private": true, "private": true,
"proxy": "http://localhost:4000", "proxy": "http://localhost:4000",
"dependencies": { "dependencies": {
"@amplitude/analytics-browser": "^2.38.0", "@amplitude/analytics-browser": "^2.42.4",
"@ant-design/pro-layout": "^7.22.6", "@ant-design/pro-layout": "^7.22.6",
"@apollo/client": "^4.1.6", "@apollo/client": "^4.2.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@documenso/embed-react": "^0.6.1",
"@emotion/is-prop-valid": "^1.4.0", "@emotion/is-prop-valid": "^1.4.0",
"@fingerprintjs/fingerprintjs": "^5.1.0", "@fingerprintjs/fingerprintjs": "^5.2.0",
"@firebase/analytics": "^0.10.21", "@firebase/analytics": "^0.10.22",
"@firebase/app": "^0.14.10", "@firebase/app": "^0.14.12",
"@firebase/auth": "^1.12.2", "@firebase/auth": "^1.13.1",
"@firebase/firestore": "^4.13.0", "@firebase/firestore": "^4.14.1",
"@firebase/messaging": "^0.12.25", "@firebase/messaging": "^0.12.26",
"@jsreport/browser-client": "^3.1.0", "@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.11.2", "@reduxjs/toolkit": "^2.12.0",
"@sentry/cli": "^3.3.5", "@sentry/cli": "^3.4.3",
"@sentry/react": "^10.47.0", "@sentry/react": "^10.53.1",
"@sentry/vite-plugin": "^4.9.1", "@sentry/vite-plugin": "^4.9.1",
"@splitsoftware/splitio-react": "^2.6.1",
"@tanem/react-nprogress": "^5.0.63", "@tanem/react-nprogress": "^5.0.63",
"antd": "^6.3.5", "antd": "^6.4.3",
"apollo-link-logger": "^3.0.0", "apollo-link-logger": "^3.0.0",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.14.0", "axios": "^1.16.1",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"css-box-model": "^1.2.1", "css-box-model": "^1.2.1",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"dayjs-business-days2": "^1.3.3", "dayjs-business-days2": "^1.3.3",
"dinero.js": "^1.9.1", "dinero.js": "^1.9.1",
"dotenv": "^17.3.1", "dotenv": "^17.4.2",
"env-cmd": "^11.0.0", "env-cmd": "^11.0.0",
"exifr": "^7.1.3", "exifr": "^7.1.3",
"graphql": "^16.13.2", "graphql": "^16.14.0",
"graphql-ws": "^6.0.8", "graphql-ws": "^6.0.8",
"i18next": "^25.10.10", "i18next": "^25.10.10",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"immutability-helper": "^3.1.1", "immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.41", "libphonenumber-js": "^1.13.3",
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"logrocket": "^12.1.0", "logrocket": "^12.1.1",
"markerjs2": "^2.32.7", "markerjs2": "^2.32.7",
"memoize-one": "^6.0.0", "memoize-one": "^6.0.0",
"normalize-url": "^8.1.1", "normalize-url": "^8.1.1",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"phone": "^3.1.71", "phone": "^3.1.71",
"posthog-js": "^1.364.4", "posthog-js": "^1.376.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"query-string": "^9.3.1", "query-string": "^9.3.1",
"raf-schd": "^4.0.3", "raf-schd": "^4.0.3",
"react": "^19.2.4", "react": "^19.2.6",
"react-big-calendar": "^1.19.4", "react-big-calendar": "^1.19.4",
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-cookie": "^8.1.0", "react-cookie": "^8.1.2",
"react-dom": "^19.2.4", "react-dom": "^19.2.6",
"react-grid-gallery": "^1.0.1", "react-grid-gallery": "^1.0.1",
"react-grid-layout": "^2.2.3", "react-grid-layout": "^2.2.3",
"react-i18next": "^16.6.6", "react-i18next": "^16.6.6",
@@ -72,22 +72,22 @@
"react-number-format": "^5.4.5", "react-number-format": "^5.4.5",
"react-popopo": "^2.1.9", "react-popopo": "^2.1.9",
"react-product-fruits": "^2.2.62", "react-product-fruits": "^2.2.62",
"react-redux": "^9.2.0", "react-redux": "^9.3.0",
"react-resizable": "^3.1.3", "react-resizable": "^3.1.3",
"react-router-dom": "^7.13.2", "react-router-dom": "^7.15.1",
"react-sticky": "^6.0.3", "react-sticky": "^6.0.3",
"react-virtuoso": "^4.18.3", "react-virtuoso": "^4.18.7",
"recharts": "^3.8.1", "recharts": "^3.8.1",
"redux": "^5.0.1", "redux": "^5.0.1",
"redux-actions": "^3.0.3", "redux-actions": "^3.0.3",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"redux-saga": "^1.4.2", "redux-saga": "^1.5.0",
"redux-state-sync": "^3.1.4", "redux-state-sync": "^3.1.4",
"reselect": "^5.1.1", "reselect": "^5.2.0",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"sass": "^1.98.0", "sass": "^1.100.0",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"styled-components": "^6.3.12", "styled-components": "^6.4.2",
"vite-plugin-ejs": "^1.7.0", "vite-plugin-ejs": "^1.7.0",
"web-vitals": "^5.2.0" "web-vitals": "^5.2.0"
}, },
@@ -137,14 +137,14 @@
"@rollup/rollup-linux-x64-gnu": "4.6.1" "@rollup/rollup-linux-x64-gnu": "4.6.1"
}, },
"devDependencies": { "devDependencies": {
"@ant-design/icons": "^6.1.1", "@ant-design/icons": "^6.2.3",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.28.5", "@babel/preset-react": "^7.29.7",
"@dotenvx/dotenvx": "^1.59.1", "@dotenvx/dotenvx": "^1.68.1",
"@emotion/babel-plugin": "^11.13.5", "@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.60.0",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
@@ -156,21 +156,21 @@
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.2", "eslint-plugin-react-compiler": "^19.1.0-rc.2",
"globals": "^17.4.0", "globals": "^17.6.0",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"memfs": "^4.57.1", "memfs": "^4.57.2",
"os-browserify": "^0.3.0", "os-browserify": "^0.3.0",
"playwright": "^1.58.2", "playwright": "^1.60.0",
"react-error-overlay": "^6.1.0", "react-error-overlay": "^6.1.0",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.3", "source-map-explorer": "^2.5.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-plugin-babel": "^1.6.0", "vite-plugin-babel": "^1.7.3",
"vite-plugin-eslint": "^1.8.1", "vite-plugin-eslint": "^1.8.1",
"vite-plugin-node-polyfills": "^0.26.0", "vite-plugin-node-polyfills": "^0.28.0",
"vite-plugin-pwa": "^1.2.0", "vite-plugin-pwa": "^1.3.0",
"vite-plugin-style-import": "^2.0.0", "vite-plugin-style-import": "^2.0.0",
"vitest": "^4.1.2", "vitest": "^4.1.7",
"workbox-window": "^7.4.0" "workbox-window": "^7.4.1"
} }
} }

View File

@@ -5593,29 +5593,6 @@ Demo: https://rawgit.com/Sphinxxxx/color-conversion/master/demo/index.html
----------- -----------
The following NPM packages may be included in this product:
- @splitsoftware/splitio-commons@1.6.1
- @splitsoftware/splitio-react@1.7.1
These packages each contain the following license and notice below:
Copyright © 2022 Split Software, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-----------
The following NPM packages may be included in this product: The following NPM packages may be included in this product:
- @stripe/react-stripe-js@1.9.0 - @stripe/react-stripe-js@1.9.0

View File

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

View File

@@ -1,6 +1,6 @@
import { ApolloProvider } from "@apollo/client/react"; import { ApolloProvider } from "@apollo/client/react";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react"; import { FeatureFlagProvider, useFeatureFlagClient } from "../feature-flags/splitio-react-replacement";
import { ConfigProvider } from "antd"; import { ConfigProvider } from "antd";
import enLocale from "antd/es/locale/en_US"; import enLocale from "antd/es/locale/en_US";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
@@ -16,23 +16,21 @@ import client from "../utils/GraphQLClient";
import App from "./App"; import App from "./App";
import getTheme from "./themeProvider"; import getTheme from "./themeProvider";
// Base Split configuration
const config = { const config = {
core: { core: {
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
key: "anon" key: "anon"
} }
}; };
function SplitClientProvider({ children }) { function FeatureFlagClientProvider({ children }) {
const imexshopid = useSelector((state) => state.user.imexshopid); const imexshopid = useSelector((state) => state.user.imexshopid);
const splitClient = useSplitClient({ key: imexshopid || "anon" }); const featureFlagClient = useFeatureFlagClient({ key: imexshopid || "anon" });
useEffect(() => { useEffect(() => {
if (import.meta.env.DEV && splitClient && imexshopid) { if (import.meta.env.DEV && featureFlagClient && imexshopid) {
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`); console.log(`Feature flag client initialized with key: ${imexshopid}, isReady: ${featureFlagClient.isReady}`);
} }
}, [splitClient, imexshopid]); }, [featureFlagClient, imexshopid]);
return children; return children;
} }
@@ -124,11 +122,11 @@ function AppContainer() {
<ApolloProvider client={client}> <ApolloProvider client={client}>
<ConfigProvider input={antdInput} locale={enLocale} theme={theme} form={antdForm}> <ConfigProvider input={antdInput} locale={enLocale} theme={theme} form={antdForm}>
<GlobalLoadingBar /> <GlobalLoadingBar />
<SplitFactoryProvider config={config}> <FeatureFlagProvider config={config}>
<SplitClientProvider> <FeatureFlagClientProvider>
<App /> <App />
</SplitClientProvider> </FeatureFlagClientProvider>
</SplitFactoryProvider> </FeatureFlagProvider>
</ConfigProvider> </ConfigProvider>
</ApolloProvider> </ApolloProvider>
</CookiesProvider> </CookiesProvider>

View File

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

View File

@@ -1,6 +1,6 @@
import { useSplitClient } from "@splitsoftware/splitio-react"; import { useSplitClient } from "../feature-flags/splitio-react-replacement";
import { Button, Result } from "antd"; import { Button, Result } from "antd";
import LogRocket from "logrocket"; //import LogRocket from "logrocket";
import { lazy, Suspense, useEffect, useState } from "react"; import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
@@ -101,13 +101,13 @@ export function App({
client.setAttribute("imexshopid", bodyshop.imexshopid); client.setAttribute("imexshopid", bodyshop.imexshopid);
if (client.getTreatment("LogRocket_Tracking") === "on") { if (client.getTreatment("LogRocket_Tracking") === "on") {
console.log("LR Start"); // console.log("LR Start");
LogRocket.init( // LogRocket.init(
InstanceRenderMgr({ // InstanceRenderMgr({
imex: "gvfvfw/bodyshopapp", // imex: "gvfvfw/bodyshopapp",
rome: "rome-online/rome-online" // rome: "rome-online/rome-online"
}) // })
); // );
} }
} }
}, [bodyshop, client, currentUser.authorized]); }, [bodyshop, client, currentUser.authorized]);
@@ -225,13 +225,22 @@ export function App({
path="/parts/*" path="/parts/*"
element={ element={
<ErrorBoundary> <ErrorBoundary>
<PrivateRoute isAuthorized={currentUser.authorized} /> <SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
<PrivateRoute isAuthorized={currentUser.authorized} />
</SocketProvider>
</ErrorBoundary> </ErrorBoundary>
} }
> >
<Route path="*" element={<SimplifiedPartsPageContainer />} /> <Route path="*" element={<SimplifiedPartsPageContainer />} />
</Route> </Route>
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}> <Route
path="/edit/*"
element={
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
<PrivateRoute isAuthorized={currentUser.authorized} />
</SocketProvider>
}
>
<Route path="*" element={<DocumentEditorContainer />} /> <Route path="*" element={<DocumentEditorContainer />} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -509,3 +509,55 @@
pointer-events: none !important; pointer-events: none !important;
} }
} }
.esignature-embed {
display: block;
width: 100%;
height: 100%;
border-width: 0;
}
.esignature-modal {
.ant-modal {
top: 16px;
max-width: calc(100vw - 32px);
padding-bottom: 0;
}
.ant-modal-content {
display: flex;
flex-direction: column;
max-height: calc(100vh - 32px);
}
.ant-modal-body {
flex: 1 1 auto;
min-height: 0;
}
}
.esignature-modal-frame {
width: 100%;
height: calc(100vh - 150px);
min-height: 320px;
overflow: hidden;
}
@media (max-width: 768px), (max-height: 560px) {
.esignature-modal {
.ant-modal {
top: 8px;
max-width: calc(100vw - 16px);
}
.ant-modal-content {
max-height: calc(100vh - 16px);
}
}
.esignature-modal-frame {
height: calc(100vh - 132px);
min-height: 0;
}
}

View File

@@ -37,6 +37,7 @@ const defaultTheme = (isDarkMode) => ({
isDarkMode isDarkMode
), ),
colorError: isDarkMode ? "#ff4d4f" : "#f5222d", colorError: isDarkMode ? "#ff4d4f" : "#f5222d",
colorShadow: isDarkMode ? "rgba(0, 0, 0, 0.7)" : "#000000",
colorBgBase: isDarkMode ? "#1f1f1f" : "#ffffff" // Align with Ant Design dark mode colorBgBase: isDarkMode ? "#1f1f1f" : "#ffffff" // Align with Ant Design dark mode
} }
}); });

View File

@@ -13,6 +13,7 @@ import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings"; import AuditTrailMapping from "../../utils/AuditTrailMappings";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import { buildBillUpdateAuditDetails } from "../../utils/auditTrailDetails";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import BillFormContainer from "../bill-form/bill-form.container"; import BillFormContainer from "../bill-form/bill-form.container";
import BillMarkExportedButton from "../bill-mark-exported-button/bill-mark-exported-button.component"; import BillMarkExportedButton from "../bill-mark-exported-button/bill-mark-exported-button.component";
@@ -134,10 +135,16 @@ export function BillDetailEditcontainer({ insertAuditTrail, bodyshop }) {
await Promise.all(updates); await Promise.all(updates);
const details = buildBillUpdateAuditDetails({
originalBill: data?.bills_by_pk,
bill,
billlines
});
insertAuditTrail({ insertAuditTrail({
jobid: bill.jobid, jobid: bill.jobid ?? data?.bills_by_pk?.jobid,
billid: search.billid, billid: search.billid,
operation: AuditTrailMapping.billupdated(bill.invoice_number), operation: AuditTrailMapping.billupdated(bill.invoice_number, details),
type: "billupdated" type: "billupdated"
}); });

View File

@@ -1,5 +1,5 @@
import { useApolloClient, useMutation } from "@apollo/client/react"; import { useApolloClient, useMutation } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Checkbox, Divider, Form, Modal, Space } from "antd"; import { Button, Checkbox, Divider, Form, Modal, Space } from "antd";
import _ from "lodash"; import _ from "lodash";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";

View File

@@ -1,6 +1,6 @@
import Icon, { UploadOutlined } from "@ant-design/icons"; import Icon, { UploadOutlined } from "@ant-design/icons";
import { useApolloClient } from "@apollo/client/react"; import { useApolloClient } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Alert, Divider, Form, Input, Select, Space, Statistic, Switch, Upload } from "antd"; import { Alert, Divider, Form, Input, Select, Space, Statistic, Switch, Upload } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";

View File

@@ -1,5 +1,5 @@
import { useLazyQuery, useQuery } from "@apollo/client/react"; import { useLazyQuery, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { QUERY_OUTSTANDING_INVENTORY } from "../../graphql/inventory.queries"; import { QUERY_OUTSTANDING_INVENTORY } from "../../graphql/inventory.queries";

View File

@@ -1,5 +1,5 @@
import { DeleteFilled, DollarCircleFilled } from "@ant-design/icons"; import { DeleteFilled, DollarCircleFilled } from "@ant-design/icons";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Checkbox, Form, Input, InputNumber, Select, Space, Switch, Table, Tooltip } from "antd"; import { Button, Checkbox, Form, Input, InputNumber, Select, Space, Switch, Table, Tooltip } from "antd";
import { useRef } from "react"; import { useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -36,6 +36,7 @@ export function BillEnterModalLinesComponent({
const { t } = useTranslation(); const { t } = useTranslation();
const { setFieldsValue, getFieldsValue, getFieldValue } = form; const { setFieldsValue, getFieldsValue, getFieldValue } = form;
const firstFieldRefs = useRef({}); const firstFieldRefs = useRef({});
const lineDescriptionRefs = useRef({});
const CONTROL_HEIGHT = 32; const CONTROL_HEIGHT = 32;
@@ -94,6 +95,23 @@ export function BillEnterModalLinesComponent({
}); });
}; };
const focusLineDescription = (index) => {
const lineDescription = lineDescriptionRefs.current[index];
if (typeof lineDescription?.focus === "function") {
lineDescription.focus({ preventScroll: true });
return;
}
lineDescription?.resizableTextArea?.textArea?.focus?.({ preventScroll: true });
};
const focusJobLineSelect = (index) => {
window.setTimeout(() => {
firstFieldRefs.current[index]?.focus?.({ preventScroll: true });
}, 0);
};
// Only fill actual_cost when the user forward-tabs out of Retail (actual_price) // Only fill actual_cost when the user forward-tabs out of Retail (actual_price)
const autofillActualCost = (index) => { const autofillActualCost = (index) => {
if (bodyshop.accountingconfig?.disableBillCostCalculation) return; if (bodyshop.accountingconfig?.disableBillCostCalculation) return;
@@ -195,6 +213,12 @@ export function BillEnterModalLinesComponent({
minHeight: `${CONTROL_HEIGHT}px` minHeight: `${CONTROL_HEIGHT}px`
}} }}
allowRemoved={form.getFieldValue("is_credit_memo") || false} allowRemoved={form.getFieldValue("is_credit_memo") || false}
onInputKeyDown={(event) => {
if (event.key !== "Tab" || event.shiftKey || event.defaultPrevented) return;
event.preventDefault();
focusLineDescription(index);
}}
onSelect={(value, opt) => { onSelect={(value, opt) => {
// IMPORTANT: // IMPORTANT:
// Do NOT autofill actual_cost here. It should only fill when the user forward-tabs // Do NOT autofill actual_cost here. It should only fill when the user forward-tabs
@@ -221,6 +245,7 @@ export function BillEnterModalLinesComponent({
}; };
}) })
}); });
focusJobLineSelect(index);
}} }}
/> />
) )
@@ -236,7 +261,16 @@ export function BillEnterModalLinesComponent({
label: t("billlines.fields.line_desc"), label: t("billlines.fields.line_desc"),
rules: [{ required: true }] rules: [{ required: true }]
}), }),
formInput: () => <Input.TextArea disabled={disabled} autoSize tabIndex={0} /> formInput: (record, index) => (
<Input.TextArea
ref={(el) => {
lineDescriptionRefs.current[index] = el;
}}
disabled={disabled}
autoSize
tabIndex={0}
/>
)
}, },
{ {
@@ -495,7 +529,9 @@ export function BillEnterModalLinesComponent({
{Enhanced_Payroll.treatment === "on" ? ( {Enhanced_Payroll.treatment === "on" ? (
<Space> <Space>
{t("joblines.fields.assigned_team", { name: employeeTeamName?.name })} {t("joblines.fields.assigned_team", { name: employeeTeamName?.name })}
{`${jobline.mod_lb_hrs} units/${t(`joblines.fields.lbr_types.${jobline.mod_lbr_ty}`)}`} {jobline
? `${jobline.mod_lb_hrs} units/${t(`joblines.fields.lbr_types.${jobline.mod_lbr_ty}`)}`
: null}
</Space> </Space>
) : null} ) : null}
@@ -506,10 +542,7 @@ export function BillEnterModalLinesComponent({
rules={[{ required: true }]} rules={[{ required: true }]}
name={[record.name, "lbr_adjustment", "mod_lbr_ty"]} name={[record.name, "lbr_adjustment", "mod_lbr_ty"]}
> >
<Select <Select allowClear options={CiecaSelect(false, true)} />
allowClear
options={CiecaSelect(false, true)}
/>
</Form.Item> </Form.Item>
{Enhanced_Payroll.treatment === "on" ? ( {Enhanced_Payroll.treatment === "on" ? (

View File

@@ -8,7 +8,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import GlobalSearch from "../global-search/global-search.component"; import GlobalSearch from "../global-search/global-search.component";
import GlobalSearchOs from "../global-search/global-search-os.component"; import GlobalSearchOs from "../global-search/global-search-os.component";
import "./breadcrumbs.styles.scss"; import "./breadcrumbs.styles.scss";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
breadcrumbs: selectBreadcrumbs, breadcrumbs: selectBreadcrumbs,

View File

@@ -1,6 +1,6 @@
import { PictureFilled } from "@ant-design/icons"; import { PictureFilled } from "@ant-design/icons";
import { useQuery } from "@apollo/client/react"; import { useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Badge, Popover } from "antd"; import { Badge, Popover } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";

View File

@@ -64,7 +64,7 @@ function normalizeJobAllocations(ack) {
* RR-specific DMS Allocations Summary * RR-specific DMS Allocations Summary
* Focused on what we actually send to RR: * Focused on what we actually send to RR:
* - ROGOG (split by taxable / non-taxable segments) * - ROGOG (split by taxable / non-taxable segments)
* - ROLABOR shell * - ROLABOR labor rows with bill hours / rates
* *
* The heavy lifting (ROGOG/ROLABOR split, cost allocation, tax flags) * The heavy lifting (ROGOG/ROLABOR split, cost allocation, tax flags)
* is now done on the backend via buildRogogFromAllocations/buildRolaborFromRogog. * is now done on the backend via buildRogogFromAllocations/buildRolaborFromRogog.
@@ -181,21 +181,30 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
const rolaborRows = useMemo(() => { const rolaborRows = useMemo(() => {
if (!rolaborPreview || !Array.isArray(rolaborPreview.ops)) return []; if (!rolaborPreview || !Array.isArray(rolaborPreview.ops)) return [];
return rolaborPreview.ops.map((op, idx) => { return rolaborPreview.ops
const rowOpCode = opCode || op.opCode; .filter((op) =>
[op.bill?.jobTotalHrs, op.bill?.billTime, op.bill?.billRate, op.amount?.custPrice, op.amount?.totalAmt]
.map((value) => Number.parseFloat(value ?? "0"))
.some((value) => !Number.isNaN(value) && value !== 0)
)
.map((op, idx) => {
const rowOpCode = opCode || op.opCode;
return { return {
key: `${op.jobNo}-${idx}`, key: `${op.jobNo}-${idx}`,
opCode: rowOpCode, opCode: rowOpCode,
jobNo: op.jobNo, jobNo: op.jobNo,
custPayTypeFlag: op.custPayTypeFlag, custPayTypeFlag: op.custPayTypeFlag,
custTxblNtxblFlag: op.custTxblNtxblFlag, custTxblNtxblFlag: op.custTxblNtxblFlag,
payType: op.bill?.payType, payType: op.bill?.payType,
amtType: op.amount?.amtType, jobTotalHrs: op.bill?.jobTotalHrs,
custPrice: op.amount?.custPrice, billTime: op.bill?.billTime,
totalAmt: op.amount?.totalAmt billRate: op.bill?.billRate,
}; amtType: op.amount?.amtType,
}); custPrice: op.amount?.custPrice,
totalAmt: op.amount?.totalAmt
};
});
}, [rolaborPreview, opCode]); }, [rolaborPreview, opCode]);
// Totals for ROGOG (sum custPrice + dlrCost over all lines) // Totals for ROGOG (sum custPrice + dlrCost over all lines)
@@ -245,6 +254,9 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
{ title: "CustPayType", dataIndex: "custPayTypeFlag", key: "custPayTypeFlag" }, { title: "CustPayType", dataIndex: "custPayTypeFlag", key: "custPayTypeFlag" },
{ title: "CustTxblFlag", dataIndex: "custTxblNtxblFlag", key: "custTxblNtxblFlag" }, { title: "CustTxblFlag", dataIndex: "custTxblNtxblFlag", key: "custTxblNtxblFlag" },
{ title: "PayType", dataIndex: "payType", key: "payType" }, { title: "PayType", dataIndex: "payType", key: "payType" },
{ title: "JobTotalHrs", dataIndex: "jobTotalHrs", key: "jobTotalHrs" },
{ title: "BillTime", dataIndex: "billTime", key: "billTime" },
{ title: "BillRate", dataIndex: "billRate", key: "billRate" },
{ title: "AmtType", dataIndex: "amtType", key: "amtType" }, { title: "AmtType", dataIndex: "amtType", key: "amtType" },
{ title: "CustPrice", dataIndex: "custPrice", key: "custPrice" }, { title: "CustPrice", dataIndex: "custPrice", key: "custPrice" },
{ title: "TotalAmt", dataIndex: "totalAmt", key: "totalAmt" } { title: "TotalAmt", dataIndex: "totalAmt", key: "totalAmt" }
@@ -317,12 +329,13 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
children: ( children: (
<> <>
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}> <Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
This mirrors the shell that would be sent for ROLABOR when all financials are carried in GOG. This mirrors the labor rows RR will receive, including weighted bill hours and rates derived from the
job&apos;s labor lines.
</Typography.Paragraph> </Typography.Paragraph>
<ResponsiveTable <ResponsiveTable
pagination={false} pagination={false}
columns={rolaborColumns} columns={rolaborColumns}
mobileColumnKeys={["jobNo", "opCode", "breakOut", "itemType"]} mobileColumnKeys={["jobNo", "opCode", "billRate", "custPrice"]}
rowKey="key" rowKey="key"
dataSource={rolaborRows} dataSource={rolaborRows}
locale={{ emptyText: "No ROLABOR lines would be generated." }} locale={{ emptyText: "No ROLABOR lines would be generated." }}

View File

@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser, currentUser: selectCurrentUser,

View File

@@ -1,4 +1,4 @@
import { Button, Col } from "antd"; import { Button, Checkbox, Col } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component"; import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -49,7 +49,13 @@ export default function PBSCustomerSelector({ bodyshop, socket }) {
if (!open) return null; if (!open) return null;
const columns = [ const columns = [
{ title: t("jobs.fields.dms.id"), dataIndex: "ContactId", key: "ContactId" }, { title: t("jobs.fields.dms.id"), dataIndex: "Code", key: "ContactId" },
{
title: t("jobs.fields.dms.IsARCustomer"),
dataIndex: "IsARCustomer",
key: "IsARCustomer",
render: (text, record) => <Checkbox checked={record.IsARCustomer} disabled />
},
{ {
title: t("jobs.fields.dms.name1"), title: t("jobs.fields.dms.name1"),
key: "name1", key: "name1",

View File

@@ -26,7 +26,7 @@ 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 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 { DMS_MAP } from "../../utils/dmsUtils"; import { DMS_MAP } from "../../utils/dmsUtils";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
/** /**
* CDK-like DMS post form: * CDK-like DMS post form:

View File

@@ -1,5 +1,5 @@
import axios from "axios"; import axios from "axios";
import { Result } from "antd"; import { Result, theme } from "antd";
import * as markerjs2 from "markerjs2"; import * as markerjs2 from "markerjs2";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -9,6 +9,12 @@ import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selecto
import { handleUpload } from "../documents-local-upload/documents-local-upload.utility"; import { handleUpload } from "../documents-local-upload/documents-local-upload.utility";
import LoadingSpinner from "../loading-spinner/loading-spinner.component"; import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import {
addGreyscaleButtonToMarkerArea,
addImageHistoryUndoToMarkerArea,
applyGreyscaleToMarkerAreaImage,
setMarkerAreaImageSource
} from "./document-editor.utility";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser, currentUser: selectCurrentUser,
@@ -24,7 +30,9 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
const [imageLoading, setImageLoading] = useState(true); const [imageLoading, setImageLoading] = useState(true);
const markerArea = useRef(null); const markerArea = useRef(null);
const imageHistory = useRef([]);
const { t } = useTranslation(); const { t } = useTranslation();
const { token } = theme.useToken();
const notification = useNotification(); const notification = useNotification();
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@@ -32,6 +40,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
async (dataUrl) => { async (dataUrl) => {
if (uploading) return; if (uploading) return;
setUploading(true); setUploading(true);
setLoading(true);
const blob = await b64toBlob(dataUrl); const blob = await b64toBlob(dataUrl);
const nameWithoutExt = filename.split(".").slice(0, -1).join(".").trim(); const nameWithoutExt = filename.split(".").slice(0, -1).join(".").trim();
const parts = nameWithoutExt.split("-"); const parts = nameWithoutExt.split("-");
@@ -70,6 +79,23 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
[filename, jobid, notification, uploading] [filename, jobid, notification, uploading]
); );
const handleGreyscale = useCallback(() => {
if (!imgRef.current || loading || uploaded || imageLoading || !imageLoaded) return;
imageHistory.current.push(imgRef.current.src);
applyGreyscaleToMarkerAreaImage(markerArea.current, imgRef.current);
}, [imageLoaded, imageLoading, loading, uploaded]);
const undoImageEdit = useCallback(() => {
if (!imgRef.current) return;
const previousSrc = imageHistory.current.pop();
if (previousSrc) {
setMarkerAreaImageSource(markerArea.current, imgRef.current, previousSrc);
}
}, []);
useEffect(() => { useEffect(() => {
if (imgRef.current !== null && imageLoaded && !markerArea.current) { if (imgRef.current !== null && imageLoaded && !markerArea.current) {
// create a marker.js MarkerArea // create a marker.js MarkerArea
@@ -93,8 +119,10 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
markerArea.current.renderImageQuality = 1; markerArea.current.renderImageQuality = 1;
//markerArea.current.settings.displayMode = "inline"; //markerArea.current.settings.displayMode = "inline";
markerArea.current.show(); markerArea.current.show();
addGreyscaleButtonToMarkerArea(markerArea.current, handleGreyscale, t("documents.labels.greyscale"));
addImageHistoryUndoToMarkerArea(markerArea.current, () => imageHistory.current.length > 0, undoImageEdit);
} }
}, [triggerUpload, imageLoaded]); }, [handleGreyscale, imageLoaded, t, triggerUpload, undoImageEdit]);
useEffect(() => { useEffect(() => {
if (!imageUrl) return; if (!imageUrl) return;
@@ -106,6 +134,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
try { try {
const response = await axios.get(imageUrl, { responseType: "blob", signal: controller.signal }); const response = await axios.get(imageUrl, { responseType: "blob", signal: controller.signal });
const blobUrl = URL.createObjectURL(response.data); const blobUrl = URL.createObjectURL(response.data);
imageHistory.current = [];
setLoadedImageUrl((prevUrl) => { setLoadedImageUrl((prevUrl) => {
if (prevUrl) URL.revokeObjectURL(prevUrl); if (prevUrl) URL.revokeObjectURL(prevUrl);
return blobUrl; return blobUrl;
@@ -142,7 +171,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
} }
return ( return (
<div> <div style={{ background: token.colorBgBase, color: token.colorText, minHeight: "100vh" }}>
{!loading && !uploaded && loadedImageUrl && ( {!loading && !uploaded && loadedImageUrl && (
<img <img
ref={imgRef} ref={imgRef}
@@ -158,7 +187,12 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
{(loading || imageLoading || !imageLoaded) && !uploaded && ( {(loading || imageLoading || !imageLoaded) && !uploaded && (
<LoadingSpinner message={t("documents.labels.uploading")} /> <LoadingSpinner message={t("documents.labels.uploading")} />
)} )}
{uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />} {uploaded && (
<Result
status="success"
title={<span style={{ color: token.colorText }}>{t("documents.successes.edituploaded")}</span>}
/>
)}
</div> </div>
); );
} }

View File

@@ -1,15 +1,21 @@
//import "tui-image-editor/dist/tui-image-editor.css"; //import "tui-image-editor/dist/tui-image-editor.css";
import axios from "axios"; import axios from "axios";
import { Result } from "antd"; import { Result, theme } from "antd";
import * as markerjs2 from "markerjs2"; import * as markerjs2 from "markerjs2";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, 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";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { handleUpload } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility.js"; import { handleUpload } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility.js";
import LoadingSpinner from "../loading-spinner/loading-spinner.component"; import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import {
addGreyscaleButtonToMarkerArea,
addImageHistoryUndoToMarkerArea,
applyGreyscaleToMarkerAreaImage,
setMarkerAreaImageSource
} from "./document-editor.utility";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser, currentUser: selectCurrentUser,
@@ -27,7 +33,9 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
const [imageLoading, setImageLoading] = useState(true); const [imageLoading, setImageLoading] = useState(true);
const markerArea = useRef(null); const markerArea = useRef(null);
const imageHistory = useRef([]);
const { t } = useTranslation(); const { t } = useTranslation();
const { token } = theme.useToken();
const notification = useNotification(); const notification = useNotification();
const triggerUpload = useCallback( const triggerUpload = useCallback(
@@ -57,6 +65,23 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
[bodyshop, currentUser, document, notification] [bodyshop, currentUser, document, notification]
); );
const handleGreyscale = useCallback(() => {
if (!imgRef.current || loading || uploaded || imageLoading || !imageLoaded) return;
imageHistory.current.push(imgRef.current.src);
applyGreyscaleToMarkerAreaImage(markerArea.current, imgRef.current);
}, [imageLoaded, imageLoading, loading, uploaded]);
const undoImageEdit = useCallback(() => {
if (!imgRef.current) return;
const previousSrc = imageHistory.current.pop();
if (previousSrc) {
setMarkerAreaImageSource(markerArea.current, imgRef.current, previousSrc);
}
}, []);
useEffect(() => { useEffect(() => {
if (imgRef.current !== null && imageLoaded && !markerArea.current) { if (imgRef.current !== null && imageLoaded && !markerArea.current) {
// create a marker.js MarkerArea // create a marker.js MarkerArea
@@ -80,8 +105,10 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
markerArea.current.renderImageQuality = 1; markerArea.current.renderImageQuality = 1;
//markerArea.current.settings.displayMode = "inline"; //markerArea.current.settings.displayMode = "inline";
markerArea.current.show(); markerArea.current.show();
addGreyscaleButtonToMarkerArea(markerArea.current, handleGreyscale, t("documents.labels.greyscale"));
addImageHistoryUndoToMarkerArea(markerArea.current, () => imageHistory.current.length > 0, undoImageEdit);
} }
}, [triggerUpload, imageLoaded]); }, [handleGreyscale, imageLoaded, t, triggerUpload, undoImageEdit]);
useEffect(() => { useEffect(() => {
if (!document?.id) return; if (!document?.id) return;
@@ -100,6 +127,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
} }
); );
const blobUrl = URL.createObjectURL(response.data); const blobUrl = URL.createObjectURL(response.data);
imageHistory.current = [];
setImageUrl((prevUrl) => { setImageUrl((prevUrl) => {
if (prevUrl) URL.revokeObjectURL(prevUrl); if (prevUrl) URL.revokeObjectURL(prevUrl);
return blobUrl; return blobUrl;
@@ -134,7 +162,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
} }
return ( return (
<div> <div style={{ background: token.colorBgBase, color: token.colorText, minHeight: "100vh" }}>
{!loading && !uploaded && imageUrl && ( {!loading && !uploaded && imageUrl && (
<img <img
ref={imgRef} ref={imgRef}
@@ -150,7 +178,12 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
{(loading || imageLoading || !imageLoaded) && !uploaded && ( {(loading || imageLoading || !imageLoaded) && !uploaded && (
<LoadingSpinner message={t("documents.labels.uploading")} /> <LoadingSpinner message={t("documents.labels.uploading")} />
)} )}
{uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />} {uploaded && (
<Result
status="success"
title={<span style={{ color: token.colorText }}>{t("documents.successes.edituploaded")}</span>}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,123 @@
/**
* Converts an image element to a greyscale data URL.
* @param imageElement
* @returns {string}
*/
export function convertImageElementToGreyscaleDataUrl(imageElement) {
if (!imageElement?.naturalWidth || !imageElement?.naturalHeight) {
throw new Error("Image must be loaded before it can be converted to greyscale.");
}
const canvas = document.createElement("canvas");
canvas.width = imageElement.naturalWidth;
canvas.height = imageElement.naturalHeight;
const context = canvas.getContext("2d");
context.drawImage(imageElement, 0, 0);
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
for (let i = 0; i < pixels.length; i += 4) {
const luminance = Math.round(pixels[i] * 0.299 + pixels[i + 1] * 0.587 + pixels[i + 2] * 0.114);
pixels[i] = luminance;
pixels[i + 1] = luminance;
pixels[i + 2] = luminance;
}
context.putImageData(imageData, 0, 0);
return canvas.toDataURL("image/jpeg", 1);
}
/**
* Adds a greyscale button to the marker area controls if it doesn't already exist.
* @param markerArea
* @param onGreyscale
* @param title
*/
export function addGreyscaleButtonToMarkerArea(markerArea, onGreyscale, title) {
requestAnimationFrame(() => {
const renderButton = markerArea?.coverDiv?.querySelector?.('[data-action="render"]');
if (!renderButton || markerArea.coverDiv.querySelector('[data-action="greyscale"]')) return;
const greyscaleButton = document.createElement("div");
greyscaleButton.className = renderButton.className;
greyscaleButton.innerHTML =
'<svg viewBox="0 0 24 24"><path d="M12 2a10 10 0 1 0 0 20V2zm0 2.25v15.5a7.75 7.75 0 0 1 0-15.5z"/></svg>';
greyscaleButton.setAttribute("role", "button");
greyscaleButton.setAttribute("data-action", "greyscale");
greyscaleButton.setAttribute("aria-label", title);
greyscaleButton.title = title;
greyscaleButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
onGreyscale();
});
renderButton.parentElement.insertBefore(greyscaleButton, renderButton);
});
}
/**
* Applies a greyscale filter to the image in the marker area and updates the image source.
* @param markerArea
* @param imageElement
* @returns {string}
*/
export function applyGreyscaleToMarkerAreaImage(markerArea, imageElement) {
const dataUrl = convertImageElementToGreyscaleDataUrl(imageElement);
setMarkerAreaImageSource(markerArea, imageElement, dataUrl);
return dataUrl;
}
/**
* Sets the image source for the marker area and updates the editing target if it's an image element.
* @param markerArea
* @param imageElement
* @param src
*/
export function setMarkerAreaImageSource(markerArea, imageElement, src) {
imageElement.src = src;
if (markerArea?.editingTarget instanceof HTMLImageElement) {
markerArea.editingTarget.src = src;
}
}
/**
* Adds undo functionality for image edits to the marker area by tracking the state before and after undo actions.
* @param markerArea
* @param canUndoImage
* @param undoImage
*/
export function addImageHistoryUndoToMarkerArea(markerArea, canUndoImage, undoImage) {
requestAnimationFrame(() => {
const undoButton = markerArea?.coverDiv?.querySelector?.('[data-action="undo"]');
if (!undoButton || undoButton.dataset.imageHistoryUndo === "true") return;
let markerStateBeforeUndo = null;
undoButton.dataset.imageHistoryUndo = "true";
undoButton.addEventListener(
"click",
() => {
markerStateBeforeUndo = JSON.stringify(markerArea.getState(true));
},
true
);
undoButton.addEventListener("click", () => {
const markerStateAfterUndo = JSON.stringify(markerArea.getState(true));
if (markerStateBeforeUndo === markerStateAfterUndo && canUndoImage()) {
undoImage();
}
markerStateBeforeUndo = null;
});
});
}

View File

@@ -4,6 +4,7 @@ import i18n from "i18next";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
import { INSERT_NEW_DOCUMENT } from "../../graphql/documents.queries"; import { INSERT_NEW_DOCUMENT } from "../../graphql/documents.queries";
import { axiosAuthInterceptorId } from "../../utils/CleanAxios"; import { axiosAuthInterceptorId } from "../../utils/CleanAxios";
import { replaceAccents } from "../../utils/replaceAccents.js";
import client from "../../utils/GraphQLClient"; import client from "../../utils/GraphQLClient";
//Context: currentUserEmail, bodyshop, jobid, invoiceid //Context: currentUserEmail, bodyshop, jobid, invoiceid
@@ -144,32 +145,3 @@ export const uploadToS3 = async (
if (onError) onError(JSON.stringify(error.message)); if (onError) onError(JSON.stringify(error.message));
} }
}; };
function replaceAccents(str) {
// Verifies if the String has accents and replace them
if (str.search(/[\xC0-\xFF]/g) > -1) {
str = str
.replace(/[\xC0-\xC5]/g, "A")
.replace(/[\xC6]/g, "AE")
.replace(/[\xC7]/g, "C")
.replace(/[\xC8-\xCB]/g, "E")
.replace(/[\xCC-\xCF]/g, "I")
.replace(/[\xD0]/g, "D")
.replace(/[\xD1]/g, "N")
.replace(/[\xD2-\xD6\xD8]/g, "O")
.replace(/[\xD9-\xDC]/g, "U")
.replace(/[\xDD]/g, "Y")
.replace(/[\xDE]/g, "P")
.replace(/[\xE0-\xE5]/g, "a")
.replace(/[\xE6]/g, "ae")
.replace(/[\xE7]/g, "c")
.replace(/[\xE8-\xEB]/g, "e")
.replace(/[\xEC-\xEF]/g, "i")
.replace(/[\xF1]/g, "n")
.replace(/[\xF2-\xF6\xF8]/g, "o")
.replace(/[\xF9-\xFC]/g, "u")
.replace(/[\xFE]/g, "p")
.replace(/[\xFD\xFF]/g, "y");
}
return str;
}

View File

@@ -9,7 +9,7 @@ import AlertComponent from "../alert/alert.component";
import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-documents-gallery.external.component"; import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-documents-gallery.external.component";
import JobsDocumentsLocalGalleryExternalComponent from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component"; import JobsDocumentsLocalGalleryExternalComponent 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 { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
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";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({

View File

@@ -0,0 +1,105 @@
import { UploadOutlined } from "@ant-design/icons";
import { Button, Upload } from "antd";
import axios from "axios";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
setEsignatureContext: (context) =>
dispatch(
setModalContext({
context,
modal: "esignature"
})
)
});
export function EsignatureCustomDocument({
bodyshop,
disabled = false,
jobId,
setEsignatureContext,
showUnavailable = false
}) {
const [loading, setLoading] = useState(false);
const notification = useNotification();
const { t } = useTranslation();
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
const isDisabled = disabled || !esignatureEnabled;
if (!esignatureEnabled && !showUnavailable) {
return null;
}
const uploadCustomDocument = async ({ file, onError, onSuccess }) => {
if (isDisabled) {
return;
}
const formData = new FormData();
formData.append("document", file);
formData.append("jobid", jobId);
formData.append("bodyshop", JSON.stringify(bodyshop));
setLoading(true);
try {
const {
data: { token, documentId, envelopeId }
} = await axios.post("/esign/new-custom", formData, {
headers: {
"Content-Type": "multipart/form-data"
}
});
setEsignatureContext({ context: { token, documentId, envelopeId, jobid: jobId } });
onSuccess?.({ token, documentId, envelopeId });
} catch (error) {
notification.error({
title: t("esignature.errors.upload_title"),
description: error?.response?.data?.error || error?.response?.data?.message || error.message
});
onError?.(error);
} finally {
setLoading(false);
}
};
return (
<Upload
accept="application/pdf,.pdf"
beforeUpload={(file) => {
if (file.type === "application/pdf" || file.name?.toLowerCase().endsWith(".pdf")) {
return true;
}
notification.error({
title: t("esignature.errors.upload_title"),
description: t("esignature.errors.pdf_only")
});
return Upload.LIST_IGNORE;
}}
customRequest={uploadCustomDocument}
disabled={isDisabled}
maxCount={1}
showUploadList={false}
multiple={false}
>
<Button disabled={isDisabled} icon={<UploadOutlined />} loading={loading}>
{t("esignature.actions.upload_document")}
</Button>
</Upload>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(EsignatureCustomDocument);

View File

@@ -0,0 +1,113 @@
import { EmbedUpdateDocumentV1 } from "@documenso/embed-react";
import { Modal, notification, Result } from "antd";
import axios from "axios";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { toggleModalVisible } from "../../redux/modals/modals.actions";
import { selectEsignature } from "../../redux/modals/modals.selectors";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { useState } from "react";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
const mapStateToProps = createStructuredSelector({
esignatureModal: selectEsignature,
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
});
const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("esignature"))
});
export function EsignatureModalContainer({ esignatureModal, toggleModalVisible, bodyshop, currentUser }) {
const { t } = useTranslation();
const { open, context } = esignatureModal;
const { token, envelopeId, documentId, jobid } = context;
const [distributing, setDistributing] = useState(false);
const hasToken = Boolean(token);
if (!hasDocumensoApiKey(bodyshop)) {
return null;
}
return (
<Modal
open={open}
title={InstanceRenderManager({
imex: t("jobs.labels.esignature_imex"),
rome: t("jobs.labels.esignature_rome")
})}
onOk={async () => {
if (!hasToken) {
return;
}
try {
setDistributing(true);
await axios.post("/esign/distribute", {
documentId,
envelopeId,
jobid,
bodyshopid: bodyshop.id
});
toggleModalVisible();
} catch (error) {
notification.error({
message: t("esignature.distribute_error"),
description: error?.response?.data?.message || error.message
});
}
setDistributing(false);
}}
onCancel={async () => {
if (!hasToken) {
toggleModalVisible();
return;
}
try {
await axios.post("/esign/delete", {
documentId,
envelopeId,
bodyshopid: bodyshop.id
});
toggleModalVisible();
} catch (error) {
notification.error({
message: t("esignature.cancel_error"),
description: error?.response?.data?.message || error.message
});
}
}}
okButtonProps={{ loading: distributing, style: hasToken ? undefined : { display: "none" } }}
okText={t("esignature.actions.distribute")}
destroyOnHidden
width="calc(100vw - 32px)"
wrapClassName="esignature-modal"
styles={{ body: { overflow: "hidden", padding: 0 } }}
>
<div className="esignature-modal-frame">
{hasToken ? (
<EmbedUpdateDocumentV1
presignToken={token}
host="https://sign.imex.online"
documentId={documentId}
externalId={`${jobid}|${currentUser?.email}`}
className="esignature-embed"
onDocumentUpdated={(data) => {
console.log("Document updated:", data);
}}
/>
) : (
<Result status="warning" title={t("esignature.errors.no_token")} />
)}
</div>
</Modal>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(EsignatureModalContainer);

View File

@@ -5,6 +5,7 @@ import { connect } from "react-redux";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import WssStatusDisplayComponent from "../../components/wss-status-display/wss-status-display.component.jsx"; import WssStatusDisplayComponent from "../../components/wss-status-display/wss-status-display.component.jsx";
import { useTreatment } from "../../feature-flags/splitio-react-replacement.jsx";
import { selectIsPartsEntry } from "../../redux/application/application.selectors.js"; import { selectIsPartsEntry } from "../../redux/application/application.selectors.js";
import InstanceRenderManager from "../../utils/instanceRenderMgr.js"; import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
@@ -16,6 +17,12 @@ const mapStateToProps = createStructuredSelector({
export function GlobalFooter({ isPartsEntry }) { export function GlobalFooter({ isPartsEntry }) {
const { t } = useTranslation(); const { t } = useTranslation();
const testFlagTreatment = useTreatment({ name: "TEST_FLAG" });
const testFlagEnabled = testFlagTreatment === "on";
const testFlagIndicator = testFlagEnabled ? (
<div style={{ fontWeight: 600, marginTop: 4 }}>Test Feature Flag Enabled</div>
) : null;
if (isPartsEntry) { if (isPartsEntry) {
return ( return (
@@ -38,6 +45,7 @@ export function GlobalFooter({ isPartsEntry }) {
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}> <Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
Disclaimer & Notices Disclaimer & Notices
</Link> </Link>
{testFlagIndicator}
</div> </div>
</Footer> </Footer>
); );
@@ -74,6 +82,7 @@ export function GlobalFooter({ isPartsEntry }) {
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}> <Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
Disclaimer & Notices Disclaimer & Notices
</Link> </Link>
{testFlagIndicator}
</div> </div>
</Footer> </Footer>
); );

View File

@@ -2,7 +2,7 @@
import { BellFilled } from "@ant-design/icons"; import { BellFilled } from "@ant-design/icons";
import { useQuery } from "@apollo/client/react"; import { useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Badge, Layout, Menu, Spin, Tooltip } from "antd"; import { Badge, Layout, Menu, Spin, Tooltip } from "antd";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";

View File

@@ -1,6 +1,6 @@
import { SyncOutlined } from "@ant-design/icons"; import { SyncOutlined } from "@ant-design/icons";
import { useQuery } from "@apollo/client/react"; import { useQuery } from "@apollo/client/react";
import { Button, Card, Col, Row, Tag } from "antd"; import { Button, Card, Checkbox, Col, Row, Space, Tag } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component"; import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
@@ -12,6 +12,9 @@ import { DateTimeFormatter } from "../../utils/DateFormatter";
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 UpsellComponent, { upsellEnum } from "../upsell/upsell.component"; import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
import axios from "axios";
import { useNotification } from "../../contexts/Notifications/notificationContext";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
@@ -23,6 +26,8 @@ export default connect(mapStateToProps, mapDispatchToProps)(JobAuditTrail);
export function JobAuditTrail({ bodyshop, jobId }) { export function JobAuditTrail({ bodyshop, jobId }) {
const { t } = useTranslation(); const { t } = useTranslation();
const notification = useNotification();
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
const { loading, data, refetch } = useQuery(QUERY_AUDIT_TRAIL, { const { loading, data, refetch } = useQuery(QUERY_AUDIT_TRAIL, {
variables: { jobid: jobId }, variables: { jobid: jobId },
skip: !jobId, skip: !jobId,
@@ -53,6 +58,145 @@ export function JobAuditTrail({ bodyshop, jobId }) {
) )
} }
]; ];
const esigColumns = [
{
title: t("esignature.fields.created_at"),
dataIndex: "created_at",
key: "created_at",
render: (text) => <DateTimeFormatter>{text}</DateTimeFormatter>
},
{
title: t("esignature.fields.updated_at"),
dataIndex: "updated_at",
key: "updated_at",
render: (text) => <DateTimeFormatter>{text}</DateTimeFormatter>
},
{
title: t("esignature.fields.title"),
dataIndex: "title",
key: "title",
render: (text) => (
<BlurWrapperComponent featureName="audit" bypass>
<div>{text}</div>
</BlurWrapperComponent>
)
},
{
title: t("esignature.fields.external_document_id"),
dataIndex: "external_document_id",
key: "external_document_id",
render: (text) => (
<BlurWrapperComponent featureName="audit" bypass>
<div>{text}</div>
</BlurWrapperComponent>
)
},
{
title: t("esignature.fields.status"),
dataIndex: "status",
key: "status",
render: (text) => (
<BlurWrapperComponent featureName="audit" bypass>
<div>{text}</div>
</BlurWrapperComponent>
)
},
{
title: t("esignature.fields.opened"),
dataIndex: "opened",
key: "opened",
render: (text) => <Checkbox checked={text} disabled />
},
{
title: t("esignature.fields.rejected"),
dataIndex: "rejected",
key: "rejected",
render: (text) => <Checkbox checked={text} disabled />
},
{
title: t("esignature.fields.completed"),
dataIndex: "completed",
key: "completed",
render: (text) => <Checkbox checked={text} disabled />
},
{
title: t("esignature.fields.completed_at"),
dataIndex: "completed_at",
key: "completed_at",
render: (text) => <DateTimeFormatter>{text}</DateTimeFormatter>
},
{
title: t("general.labels.actions"),
dataIndex: "actions",
key: "actions",
render: (_text, record) => (
<Space wrap>
<Button
disabled={record.completed_at !== null || record.status === "REJECTED"}
onClick={async () => {
logImEXEvent("job_esig_delete", {});
try {
await axios.post("/esign/delete", {
documentId: record.external_document_id,
bodyshopid: bodyshop.id
});
refetch();
} catch (error) {
console.error("Error deleting document:", error?.response?.data || error.message);
notification.error({
message: t("esignature.delete_error"),
description: error?.response?.data?.error || error.message
});
}
}}
>
{t("esignature.actions.delete")}
</Button>
<Button
onClick={async () => {
logImEXEvent("job_esig_redistribute", {});
try {
await axios.post("/esign/redistribute", {
documentId: record.external_document_id,
bodyshopid: bodyshop.id
});
//Pop the success notification. Possible audit requery required.
} catch (error) {
console.error("Error viewing document:", error?.response?.data || error.message);
notification.error({
message: t("esignature.view_error"),
description: error?.response?.data?.message || error.message
});
}
}}
>
{t("esignature.actions.redistribute")}
</Button>
<Button
onClick={async () => {
logImEXEvent("job_esig_view", {});
try {
const response = await axios.post("/esign/view", {
documentId: record.external_document_id,
bodyshopid: bodyshop.id
});
window.open(response.data?.document?.downloadUrl, "_blank");
} catch (error) {
console.error("Error viewing document:", error?.response?.data || error.message);
notification.error({
message: t("esignature.view_error"),
description: error?.response?.data?.message || error.message
});
}
}}
>
{t("esignature.actions.view")}
</Button>
</Space>
)
}
];
const emailColumns = [ const emailColumns = [
{ {
title: t("audit.fields.created"), title: t("audit.fields.created"),
@@ -184,6 +328,20 @@ export function JobAuditTrail({ bodyshop, jobId }) {
/> />
</Card> </Card>
</Col> </Col>
{esignatureEnabled && (
<Col span={24}>
<Card title={t("jobs.labels.esignatures")}>
<ResponsiveTable
loading={loading}
columns={esigColumns}
mobileColumnKeys={["title", "status"]}
rowKey="id"
scroll={{ x: true }}
dataSource={data ? data.esignature_documents : []}
/>
</Card>
</Col>
)}
</Row> </Row>
); );
} }

View File

@@ -1,5 +1,5 @@
import { useQuery } from "@apollo/client/react"; import { useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { GET_LINE_TICKET_BY_PK } from "../../graphql/jobs-lines.queries"; import { GET_LINE_TICKET_BY_PK } from "../../graphql/jobs-lines.queries";

View File

@@ -1,5 +1,5 @@
import { useApolloClient } from "@apollo/client/react"; import { useApolloClient } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Popconfirm } from "antd"; import { Button, Popconfirm } from "antd";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";

View File

@@ -30,7 +30,7 @@ import JobLinesBillRefernece from "../job-lines-bill-reference/job-lines-bill-re
// import AllocationsAssignmentContainer from "../allocations-assignment/allocations-assignment.container"; // import AllocationsAssignmentContainer from "../allocations-assignment/allocations-assignment.container";
// import AllocationsBulkAssignmentContainer from "../allocations-bulk-assignment/allocations-bulk-assignment.container"; // import AllocationsBulkAssignmentContainer from "../allocations-bulk-assignment/allocations-bulk-assignment.container";
// import AllocationsEmployeeLabelContainer from "../allocations-employee-label/allocations-employee-label.container"; // import AllocationsEmployeeLabelContainer from "../allocations-employee-label/allocations-employee-label.container";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import _ from "lodash"; import _ from "lodash";
import { FaTasks } from "react-icons/fa"; import { FaTasks } from "react-icons/fa";
import { selectAuthLevel, selectBodyshop } from "../../redux/user/user.selectors"; import { selectAuthLevel, selectBodyshop } from "../../redux/user/user.selectors";
@@ -44,6 +44,7 @@ import JoblineTeamAssignment from "../job-line-team-assignment/job-line-team-ass
import JobSendPartPriceChangeComponent from "../job-send-parts-price-change/job-send-parts-price-change.component"; import JobSendPartPriceChangeComponent from "../job-send-parts-price-change/job-send-parts-price-change.component";
import PartsOrderDrawer from "../parts-order-list-table/parts-order-list-table-drawer.component"; import PartsOrderDrawer from "../parts-order-list-table/parts-order-list-table-drawer.component";
import PartsOrderModalContainer from "../parts-order-modal/parts-order-modal.container"; import PartsOrderModalContainer from "../parts-order-modal/parts-order-modal.container";
import { buildInHouseBillLines } from "./job-lines.in-house-bill-lines.utils";
import JobLinesExpander from "./job-lines-expander.component"; import JobLinesExpander from "./job-lines-expander.component";
import JobLinesPartPriceChange from "./job-lines-part-price-change.component"; import JobLinesPartPriceChange from "./job-lines-part-price-change.component";
import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component"; import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component";
@@ -595,16 +596,7 @@ export function JobLinesComponent({
isinhouse: true, isinhouse: true,
date: dayjs(), date: dayjs(),
total: 0, total: 0,
billlines: selectedLines.map((p) => ({ billlines: buildInHouseBillLines(selectedLines)
joblineid: p.id,
actual_price: p.act_price,
actual_cost: 0,
line_desc: p.line_desc,
line_remarks: p.line_remarks,
part_type: p.part_type,
quantity: p.quantity || 1,
applicable_taxes: { local: false, state: false, federal: false }
}))
} }
} }
}); });

View File

@@ -0,0 +1,11 @@
export const buildInHouseBillLines = (lines) =>
lines.map((line) => ({
joblineid: line.id,
actual_price: line.act_price,
actual_cost: 0,
line_desc: line.line_desc,
line_remarks: line.line_remarks,
part_type: line.part_type,
quantity: line.part_qty ?? line.quantity ?? 1,
applicable_taxes: { local: false, state: false, federal: false }
}));

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { buildInHouseBillLines } from "./job-lines.in-house-bill-lines.utils";
describe("buildInHouseBillLines", () => {
it("carries job line part quantity into the in-house bill line", () => {
const billLines = buildInHouseBillLines([
{
id: "job-line-1",
act_price: 125,
line_desc: "Door shell",
line_remarks: "Left",
part_type: "PAA",
part_qty: 3
}
]);
expect(billLines[0]).toMatchObject({
joblineid: "job-line-1",
actual_price: 125,
actual_cost: 0,
line_desc: "Door shell",
line_remarks: "Left",
part_type: "PAA",
quantity: 3,
applicable_taxes: { local: false, state: false, federal: false }
});
});
it("falls back to legacy quantity and then one when part quantity is absent", () => {
expect(buildInHouseBillLines([{ id: "legacy", quantity: 2 }])[0].quantity).toBe(2);
expect(buildInHouseBillLines([{ id: "missing" }])[0].quantity).toBe(1);
});
});

View File

@@ -63,7 +63,9 @@ export function JobLineDispatchButton({
} }
} }
//joblineids: selectedLines.map((l) => l.id), //joblineids: selectedLines.map((l) => l.id),
} },
refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID", "GET_JOB_BY_PK"],
awaitRefetchQueries: true
}); });
if (result.errors) { if (result.errors) {
console.log("🚀 ~ handleConvert ~ result.errors:", result.errors); console.log("🚀 ~ handleConvert ~ result.errors:", result.errors);

View File

@@ -1,4 +1,4 @@
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Form, Input, InputNumber, Modal, Select, Switch } from "antd"; import { Form, Input, InputNumber, Modal, Select, Switch } from "antd";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -67,22 +67,25 @@ export function JobLinesUpsertModalComponent({ bodyshop, open, jobLine, handleCa
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow grow> <LayoutFormRow grow>
<Form.Item label={t("joblines.fields.mod_lbr_ty")} name="mod_lbr_ty"> <Form.Item label={t("joblines.fields.mod_lbr_ty")} name="mod_lbr_ty">
<Select allowClear options={[ <Select
{ value: "LAA", label: t("joblines.fields.lbr_types.LAA") }, allowClear
{ value: "LAB", label: t("joblines.fields.lbr_types.LAB") }, options={[
{ value: "LAD", label: t("joblines.fields.lbr_types.LAD") }, { value: "LAA", label: t("joblines.fields.lbr_types.LAA") },
{ value: "LAE", label: t("joblines.fields.lbr_types.LAE") }, { value: "LAB", label: t("joblines.fields.lbr_types.LAB") },
{ value: "LAF", label: t("joblines.fields.lbr_types.LAF") }, { value: "LAD", label: t("joblines.fields.lbr_types.LAD") },
{ value: "LAG", label: t("joblines.fields.lbr_types.LAG") }, { value: "LAE", label: t("joblines.fields.lbr_types.LAE") },
{ value: "LAM", label: t("joblines.fields.lbr_types.LAM") }, { value: "LAF", label: t("joblines.fields.lbr_types.LAF") },
{ value: "LAR", label: t("joblines.fields.lbr_types.LAR") }, { value: "LAG", label: t("joblines.fields.lbr_types.LAG") },
{ value: "LAS", label: t("joblines.fields.lbr_types.LAS") }, { value: "LAM", label: t("joblines.fields.lbr_types.LAM") },
{ value: "LAU", label: t("joblines.fields.lbr_types.LAU") }, { value: "LAR", label: t("joblines.fields.lbr_types.LAR") },
{ value: "LA1", label: t("joblines.fields.lbr_types.LA1") }, { value: "LAS", label: t("joblines.fields.lbr_types.LAS") },
{ value: "LA2", label: t("joblines.fields.lbr_types.LA2") }, { value: "LAU", label: t("joblines.fields.lbr_types.LAU") },
{ value: "LA3", label: t("joblines.fields.lbr_types.LA3") }, { value: "LA1", label: t("joblines.fields.lbr_types.LA1") },
{ value: "LA4", label: t("joblines.fields.lbr_types.LA4") } { value: "LA2", label: t("joblines.fields.lbr_types.LA2") },
]} /> { value: "LA3", label: t("joblines.fields.lbr_types.LA3") },
{ value: "LA4", label: t("joblines.fields.lbr_types.LA4") }
]}
/>
</Form.Item> </Form.Item>
<Form.Item label={t("joblines.fields.op_code_desc")} name="op_code_desc"> <Form.Item label={t("joblines.fields.op_code_desc")} name="op_code_desc">
<Input /> <Input />
@@ -128,21 +131,27 @@ export function JobLinesUpsertModalComponent({ bodyshop, open, jobLine, handleCa
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow> <LayoutFormRow>
<Form.Item label={t("joblines.fields.part_type")} name="part_type"> <Form.Item label={t("joblines.fields.part_type")} name="part_type">
<Select allowClear options={[ <Select
{ value: "PAA", label: t("joblines.fields.part_types.PAA") }, allowClear
{ value: "PAC", label: t("joblines.fields.part_types.PAC") }, options={[
{ value: "PAE", label: t("joblines.fields.part_types.PAE") }, { value: "PAA", label: t("joblines.fields.part_types.PAA") },
{ value: "PAL", label: t("joblines.fields.part_types.PAL") }, { value: "PAC", label: t("joblines.fields.part_types.PAC") },
{ value: "PAM", label: t("joblines.fields.part_types.PAM") }, { value: "PAE", label: t("joblines.fields.part_types.PAE") },
{ value: "PAN", label: t("joblines.fields.part_types.PAN") }, { value: "PAL", label: t("joblines.fields.part_types.PAL") },
{ value: "PAO", label: t("joblines.fields.part_types.PAO") }, { value: "PAM", label: t("joblines.fields.part_types.PAM") },
{ value: "PAR", label: t("joblines.fields.part_types.PAR") }, { value: "PAN", label: t("joblines.fields.part_types.PAN") },
{ value: "PAS", label: t("joblines.fields.part_types.PAS") } { value: "PAO", label: t("joblines.fields.part_types.PAO") },
]} /> { value: "PAR", label: t("joblines.fields.part_types.PAR") },
{ value: "PAS", label: t("joblines.fields.part_types.PAS") }
]}
/>
</Form.Item> </Form.Item>
<Form.Item label={t("joblines.fields.oem_partno")} name="oem_partno"> <Form.Item label={t("joblines.fields.oem_partno")} name="oem_partno">
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item label={t("joblines.fields.alt_partno")} name="alt_partno">
<Input />
</Form.Item>
<Form.Item <Form.Item
label={t("joblines.fields.part_qty")} label={t("joblines.fields.part_qty")}
name="part_qty" name="part_qty"

View File

@@ -1,5 +1,5 @@
import { useMutation } from "@apollo/client/react"; import { useMutation } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import Axios from "axios"; import Axios from "axios";
import Dinero from "dinero.js"; import Dinero from "dinero.js";
import { useState } from "react"; import { useState } from "react";
@@ -14,16 +14,20 @@ import CriticalPartsScan from "../../utils/criticalPartsScan";
import UndefinedToNull from "../../utils/undefinedtonull"; import UndefinedToNull from "../../utils/undefinedtonull";
import JobLinesUpdsertModal from "./job-lines-upsert-modal.component"; import JobLinesUpdsertModal from "./job-lines-upsert-modal.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
import { insertAuditTrail } from "../../redux/application/application.actions.js";
import { buildJobLineInsertAuditDetails, buildJobLineUpdateAuditDetails } from "../../utils/auditTrailDetails.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
jobLineEditModal: selectJobLineEditModal, jobLineEditModal: selectJobLineEditModal,
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("jobLineEdit")) toggleModalVisible: () => dispatch(toggleModalVisible("jobLineEdit")),
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
}); });
function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bodyshop }) { function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bodyshop, insertAuditTrail }) {
const { const {
treatments: { CriticalPartsScanning } treatments: { CriticalPartsScanning }
} = useTreatmentsWithConfig({ } = useTreatmentsWithConfig({
@@ -74,6 +78,11 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo
notification.success({ notification.success({
title: t("joblines.successes.created") title: t("joblines.successes.created")
}); });
insertAuditTrail({
jobid: jobLineEditModal.context.jobid,
operation: AuditTrailMapping.jobmanuallineinsert(buildJobLineInsertAuditDetails(values)),
type: "jobmanuallineinsert"
});
} else { } else {
notification.error({ notification.error({
title: t("joblines.errors.creating", { title: t("joblines.errors.creating", {
@@ -103,6 +112,17 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo
notification.success({ notification.success({
title: t("joblines.successes.updated") title: t("joblines.successes.updated")
}); });
insertAuditTrail({
jobid: jobLineEditModal.context.jobid,
operation: AuditTrailMapping.joblineupdate(
values.line_desc || jobLineEditModal.context.line_desc || "manual line",
buildJobLineUpdateAuditDetails({
originalLine: jobLineEditModal.context,
values
})
),
type: "joblineupdate"
});
} else { } else {
notification.success({ notification.success({
title: t("joblines.errors.updating", { title: t("joblines.errors.updating", {

View File

@@ -16,7 +16,7 @@ import DataLabel from "../data-label/data-label.component";
import PaymentExpandedRowComponent from "../payment-expanded-row/payment-expanded-row.component"; import PaymentExpandedRowComponent from "../payment-expanded-row/payment-expanded-row.component";
import PaymentsGenerateLink from "../payments-generate-link/payments-generate-link.component"; import PaymentsGenerateLink from "../payments-generate-link/payments-generate-link.component";
import PrintWrapperComponent from "../print-wrapper/print-wrapper.component"; import PrintWrapperComponent from "../print-wrapper/print-wrapper.component";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop

View File

@@ -4,7 +4,7 @@ import { ADD_JOB_WATCHER, GET_JOB_WATCHERS, REMOVE_JOB_WATCHER } from "../../gra
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx"; import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx";
import { useIsEmployee } from "../../utils/useIsEmployee.js"; import { useIsEmployee } from "../../utils/useIsEmployee.js";

View File

@@ -1,6 +1,6 @@
import { useApolloClient, useLazyQuery, useMutation, useQuery } from "@apollo/client/react"; import { useApolloClient, useLazyQuery, useMutation, useQuery } from "@apollo/client/react";
import { gql } from "@apollo/client"; import { gql } from "@apollo/client";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Col, Row } from "antd"; import { Col, Row } from "antd";
import Axios from "axios"; import Axios from "axios";
import _ from "lodash"; import _ from "lodash";

View File

@@ -6,7 +6,7 @@ import { useCallback, 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 { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries"; import { CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions"; import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors"; import { selectJobReadOnly } from "../../redux/application/application.selectors";
@@ -224,14 +224,10 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
</> </>
)} )}
<Form.Item <Form.Item name={["ins_co_nm"]} label={t("jobs.fields.ins_co_nm")} rules={[{ required: true }]}>
name={["ins_co_nm"]}
label={t("jobs.fields.ins_co_nm")}
rules={[{ required: true }]}
>
<Select <Select
showSearch={{ showSearch={{
optionFilterProp:'label' optionFilterProp: "label"
}} }}
options={insuranceOptions} options={insuranceOptions}
/> />
@@ -250,7 +246,7 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
label={t("jobs.fields.referralsource")} label={t("jobs.fields.referralsource")}
rules={[{ required: bodyshop.enforce_referral }]} rules={[{ required: bodyshop.enforce_referral }]}
> >
<Select options={referralOptions} /> <Select showSearch={{ optionFilterProp: "label" }} options={referralOptions} />
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra"> <Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
@@ -272,19 +268,21 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
> >
<Select <Select
showSearch={{ showSearch={{
optionFilterProp: 'label', optionFilterProp: "label",
filterOption: (input, option) => filterOption: (input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}} }}
style={{ width: 200 }} style={{ width: 200 }}
options={csrOptions} options={csrOptions}
/> />
</Form.Item> </Form.Item>
)} )}
{bodyshop.enforce_conversion_category && ( {bodyshop.enforce_conversion_category && (
<Form.Item name="category" label={t("jobs.fields.category")} rules={[{ required: bodyshop.enforce_conversion_category }]}> <Form.Item
name="category"
label={t("jobs.fields.category")}
rules={[{ required: bodyshop.enforce_conversion_category }]}
>
<Select allowClear options={categoryOptions} /> <Select allowClear options={categoryOptions} />
</Form.Item> </Form.Item>
)} )}

View File

@@ -193,6 +193,9 @@ export function JobsCreateJobsInfo({ bodyshop, form, selected }) {
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.referralsource")} name="referral_source"> <Form.Item label={t("jobs.fields.referralsource")} name="referral_source">
<Select <Select
showSearch={{
optionFilterProp: "label"
}}
options={bodyshop.md_referral_sources.map((s) => ({ options={bodyshop.md_referral_sources.map((s) => ({
value: s, value: s,
label: s label: s

View File

@@ -43,19 +43,25 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
<Input disabled={jobRO} /> <Input disabled={jobRO} />
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.ded_status")} name="ded_status"> <Form.Item label={t("jobs.fields.ded_status")} name="ded_status">
<Select disabled={jobRO} options={[ <Select
{ value: "W", label: t("jobs.labels.deductible.waived") }, disabled={jobRO}
{ value: "Y", label: t("jobs.labels.deductible.stands") } options={[
]} /> { value: "W", label: t("jobs.labels.deductible.waived") },
{ value: "Y", label: t("jobs.labels.deductible.stands") }
]}
/>
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.ded_amt")} name="ded_amt"> <Form.Item label={t("jobs.fields.ded_amt")} name="ded_amt">
<CurrencyInput disabled={jobRO} min={0} /> <CurrencyInput disabled={jobRO} min={0} />
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.ded_note")} name="ded_note"> <Form.Item label={t("jobs.fields.ded_note")} name="ded_note">
<Select disabled={jobRO} options={bodyshop.md_ded_notes.map((n) => ({ <Select
value: n, disabled={jobRO}
label: n options={bodyshop.md_ded_notes.map((n) => ({
}))} /> value: n,
label: n
}))}
/>
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.policy_no")} name="policy_no"> <Form.Item label={t("jobs.fields.policy_no")} name="policy_no">
<Input disabled={jobRO} /> <Input disabled={jobRO} />
@@ -65,10 +71,14 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.ins_co_nm")} name="ins_co_nm"> <Form.Item label={t("jobs.fields.ins_co_nm")} name="ins_co_nm">
<Select disabled={jobRO} onChange={handleInsCoChange} options={bodyshop.md_ins_cos.map((s) => ({ <Select
value: s.name, disabled={jobRO}
label: s.name onChange={handleInsCoChange}
}))} /> options={bodyshop.md_ins_cos.map((s) => ({
value: s.name,
label: s.name
}))}
/>
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.ins_addr1")} name="ins_addr1"> <Form.Item label={t("jobs.fields.ins_addr1")} name="ins_addr1">
<Input disabled={jobRO} /> <Input disabled={jobRO} />
@@ -119,19 +129,30 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
} }
]} ]}
> >
<Select disabled={jobRO} allowClear options={bodyshop.md_referral_sources.map((s) => ({ <Select
value: s, disabled={jobRO}
label: s allowClear
}))} /> showSearch={{
optionFilterProp: "label"
}}
options={bodyshop.md_referral_sources.map((s) => ({
value: s,
label: s
}))}
/>
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra"> <Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
<Input disabled={jobRO} /> <Input disabled={jobRO} />
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.alt_transport")} name="alt_transport"> <Form.Item label={t("jobs.fields.alt_transport")} name="alt_transport">
<Select disabled={jobRO} allowClear options={bodyshop.appt_alt_transport.map((s) => ({ <Select
value: s, disabled={jobRO}
label: s allowClear
}))} /> options={bodyshop.appt_alt_transport.map((s) => ({
value: s,
label: s
}))}
/>
</Form.Item> </Form.Item>
</FormRow> </FormRow>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
@@ -233,10 +254,14 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
</FormRow> </FormRow>
<FormRow header={t("jobs.forms.other")}> <FormRow header={t("jobs.forms.other")}>
<Form.Item label={t("jobs.fields.category")} name="category"> <Form.Item label={t("jobs.fields.category")} name="category">
<Select disabled={jobRO} allowClear options={bodyshop.md_categories.map((s) => ({ <Select
value: s, disabled={jobRO}
label: s allowClear
}))} /> options={bodyshop.md_categories.map((s) => ({
value: s,
label: s
}))}
/>
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer"> <Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
<Input disabled={jobRO} /> <Input disabled={jobRO} />

View File

@@ -1,6 +1,6 @@
import { DownCircleFilled } from "@ant-design/icons"; import { DownCircleFilled } from "@ant-design/icons";
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react"; import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Card, Dropdown, Form, Input, Modal, Popover, Select, Space } from "antd"; import { Button, Card, Dropdown, Form, Input, Modal, Popover, Select, Space } from "antd";
import axios from "axios"; import axios from "axios";
import parsePhoneNumber from "libphonenumber-js"; import parsePhoneNumber from "libphonenumber-js";

View File

@@ -6,7 +6,7 @@ import LaborAllocationsTableComponent from "../labor-allocations-table/labor-all
import TimeTicketList from "../time-ticket-list/time-ticket-list.component"; import TimeTicketList from "../time-ticket-list/time-ticket-list.component";
import PayrollLaborAllocationsTable from "../labor-allocations-table/labor-allocations-table.payroll.component"; import PayrollLaborAllocationsTable from "../labor-allocations-table/labor-allocations-table.payroll.component";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly, jobRO: selectJobReadOnly,

View File

@@ -6,7 +6,7 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
import cleanAxios from "../../utils/CleanAxios"; import cleanAxios from "../../utils/CleanAxios";
import formatBytes from "../../utils/formatbytes"; import formatBytes from "../../utils/formatbytes";
//import yauzl from "yauzl"; //import yauzl from "yauzl";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";

View File

@@ -7,7 +7,7 @@ import JobDocuments from "./jobs-documents-gallery.component";
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";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop

View File

@@ -3,7 +3,7 @@ import { Button, Card, Col, Row, Space } from "antd";
import axios from "axios"; import axios from "axios";
import i18n from "i18next"; import i18n from "i18next";
import { isFunction } from "lodash"; import { isFunction } from "lodash";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Lightbox from "react-image-lightbox"; import Lightbox from "react-image-lightbox";
import "react-image-lightbox/style.css"; import "react-image-lightbox/style.css";
@@ -12,12 +12,12 @@ import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import DocumentsUploadImgproxyComponent from "../documents-upload-imgproxy/documents-upload-imgproxy.component"; import DocumentsUploadImgproxyComponent from "../documents-upload-imgproxy/documents-upload-imgproxy.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component";
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component"; import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
import JobsDocumentsDownloadButton from "./jobs-document-imgproxy-gallery.download.component"; import JobsDocumentsDownloadButton from "./jobs-document-imgproxy-gallery.download.component";
import JobsDocumentsGalleryReassign from "./jobs-document-imgproxy-gallery.reassign.component"; import JobsDocumentsGalleryReassign from "./jobs-document-imgproxy-gallery.reassign.component";
import JobsDocumentsDeleteButton from "./jobs-documents-imgproxy-gallery.delete.component"; import JobsDocumentsDeleteButton from "./jobs-documents-imgproxy-gallery.delete.component";
import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-imgproxy-gallery.selectall.component"; import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-imgproxy-gallery.selectall.component";
import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
@@ -38,6 +38,9 @@ function JobsDocumentsImgproxyComponent({
const [galleryImages, setGalleryImages] = useState({ images: [], other: [] }); const [galleryImages, setGalleryImages] = useState({ images: [], other: [] });
const { t } = useTranslation(); const { t } = useTranslation();
const [modalState, setModalState] = useState({ open: false, index: 0 }); const [modalState, setModalState] = useState({ open: false, index: 0 });
const [previewUrls, setPreviewUrls] = useState({});
const [previewError, setPreviewError] = useState(null);
const previewUrlsRef = useRef({});
const fetchThumbnails = useCallback(() => { const fetchThumbnails = useCallback(() => {
fetchImgproxyThumbnails({ setStateCallback: setGalleryImages, jobId, billId }); fetchImgproxyThumbnails({ setStateCallback: setGalleryImages, jobId, billId });
@@ -49,8 +52,86 @@ function JobsDocumentsImgproxyComponent({
} }
}, [data, fetchThumbnails]); }, [data, fetchThumbnails]);
useEffect(() => {
return () => {
Object.values(previewUrlsRef.current).forEach(URL.revokeObjectURL);
};
}, []);
const selectedImage = modalState.open ? galleryImages.images[modalState.index] : null;
useEffect(() => {
if (!modalState.open || !selectedImage?.id) return;
if (previewUrlsRef.current[selectedImage.id]) {
setPreviewError(null);
return;
}
const controller = new AbortController();
async function loadPreviewImage() {
setPreviewError(null);
try {
const response = await axios.post(
"/media/imgproxy/original",
{ documentId: selectedImage.id },
{
responseType: "blob",
signal: controller.signal
}
);
const blobUrl = URL.createObjectURL(response.data);
previewUrlsRef.current = {
...previewUrlsRef.current,
[selectedImage.id]: blobUrl
};
setPreviewUrls(previewUrlsRef.current);
} catch (error) {
if (axios.isCancel?.(error) || error.name === "CanceledError") return;
console.error("Failed to fetch original image blob", error);
setPreviewError(error);
}
}
loadPreviewImage();
return () => {
controller.abort();
};
}, [modalState.open, selectedImage?.id]);
useEffect(() => {
if (modalState.open && !selectedImage) {
setModalState({ open: false, index: 0 });
}
}, [modalState.open, selectedImage]);
const openEditorForImage = useCallback((image) => {
if (!image?.id) return;
const newWindow = window.open(
`${window.location.protocol}//${window.location.host}/edit?documentId=${image.id}`,
"_blank",
"noopener,noreferrer"
);
if (newWindow) newWindow.opener = null;
}, []);
const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" }); const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" });
const hasMobileAccess = HasFeatureAccess({ bodyshop, featureName: "mobile" }); const hasMobileAccess = HasFeatureAccess({ bodyshop, featureName: "mobile" });
const previewSrc = selectedImage ? previewUrls[selectedImage.id] : null;
const getLightboxImageSrc = useCallback(
(index) => {
const image = galleryImages.images[index];
return image ? previewUrls[image.id] || image.src : undefined;
},
[galleryImages.images, previewUrls]
);
return ( return (
<div> <div>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
@@ -147,30 +228,33 @@ function JobsDocumentsImgproxyComponent({
/> />
</Card> </Card>
</Col> </Col>
{modalState.open && ( {modalState.open && selectedImage && (
<Lightbox <Lightbox
toolbarButtons={[ toolbarButtons={[
<EditFilled <EditFilled
key="edit" key="edit"
onClick={() => { onClick={() => {
const newWindow = window.open( openEditorForImage(selectedImage);
`${window.location.protocol}//${window.location.host}/edit?documentId=${
galleryImages.images[modalState.index].id
}`,
"_blank",
"noopener,noreferrer"
);
if (newWindow) newWindow.opener = null;
}} }}
/> />
]} ]}
mainSrc={galleryImages.images[modalState.index].fullsize} imageLoadErrorMessage={previewError ? t("general.errors.notfound") : undefined}
nextSrc={galleryImages.images[(modalState.index + 1) % galleryImages.images.length].fullsize} mainSrc={previewSrc || selectedImage.src}
prevSrc={ mainSrcThumbnail={selectedImage.src}
nextSrc={getLightboxImageSrc((modalState.index + 1) % galleryImages.images.length)}
nextSrcThumbnail={galleryImages.images[(modalState.index + 1) % galleryImages.images.length]?.src}
prevSrc={getLightboxImageSrc(
(modalState.index + galleryImages.images.length - 1) % galleryImages.images.length
)}
prevSrcThumbnail={
galleryImages.images[(modalState.index + galleryImages.images.length - 1) % galleryImages.images.length] galleryImages.images[(modalState.index + galleryImages.images.length - 1) % galleryImages.images.length]
.fullsize ?.src
} }
onCloseRequest={() => setModalState({ open: false, index: 0 })} reactModalProps={{ ariaHideApp: false }}
onCloseRequest={() => {
setModalState({ open: false, index: 0 });
setPreviewError(null);
}}
onMovePrevRequest={() => onMovePrevRequest={() =>
setModalState({ setModalState({
...modalState, ...modalState,

View File

@@ -14,7 +14,7 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const LockWrapper = ({ featureName, bodyshop, children, disabled = true, bypass }) => { const LockWrapper = ({ featureName, bodyshop, children, disabled = true, bypass, locked }) => {
let renderedChildren = children; let renderedChildren = children;
//Mark the child prop as disabled. //Mark the child prop as disabled.
@@ -36,11 +36,13 @@ const LockWrapper = ({ featureName, bodyshop, children, disabled = true, bypass
return <span>{children}</span>; return <span>{children}</span>;
} }
return HasFeatureAccess({ featureName: featureName, bodyshop }) ? ( const hasAccess = typeof locked === "boolean" ? !locked : HasFeatureAccess({ featureName: featureName, bodyshop });
return hasAccess ? (
children children
) : ( ) : (
<Space> <Space>
{!HasFeatureAccess({ featureName: featureName, bodyshop }) && <LockOutlined style={{ color: "tomato" }} />} <LockOutlined style={{ color: "tomato" }} />
{renderedChildren} {renderedChildren}
</Space> </Space>
); );

View File

@@ -1,4 +1,3 @@
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
import { Checkbox, Form } from "antd"; import { Checkbox, Form } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
@@ -9,18 +8,18 @@ import PropTypes from "prop-types";
* @param form * @param form
* @param disabled * @param disabled
* @param onHeaderChange * @param onHeaderChange
* @param scenarioKeys
* @returns {JSX.Element} * @returns {JSX.Element}
* @constructor * @constructor
*/ */
const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange }) => { const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange, scenarioKeys }) => {
const { t } = useTranslation(); const { t } = useTranslation();
// Subscribe to all form values so that this component re-renders on changes. // Subscribe to all form values so that this component re-renders on changes.
const formValues = Form.useWatch([], form) || {}; const formValues = Form.useWatch([], form) || {};
// Determine if all scenarios for this channel are checked. // Determine if all scenarios for this channel are checked.
const allChecked = const allChecked = scenarioKeys.length > 0 && scenarioKeys.every((scenario) => formValues[scenario]?.[channel]);
notificationScenarios.length > 0 && notificationScenarios.every((scenario) => formValues[scenario]?.[channel]);
const onChange = (e) => { const onChange = (e) => {
const checked = e.target.checked; const checked = e.target.checked;
@@ -28,7 +27,7 @@ const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange
const currentValues = form.getFieldsValue(); const currentValues = form.getFieldsValue();
// Update each scenario for this channel. // Update each scenario for this channel.
const newValues = { ...currentValues }; const newValues = { ...currentValues };
notificationScenarios.forEach((scenario) => { scenarioKeys.forEach((scenario) => {
newValues[scenario] = { ...newValues[scenario], [channel]: checked }; newValues[scenario] = { ...newValues[scenario], [channel]: checked };
}); });
// Update form values. // Update form values.
@@ -50,7 +49,8 @@ ColumnHeaderCheckbox.propTypes = {
channel: PropTypes.oneOf(["app", "email", "fcm"]).isRequired, channel: PropTypes.oneOf(["app", "email", "fcm"]).isRequired,
form: PropTypes.object.isRequired, form: PropTypes.object.isRequired,
disabled: PropTypes.bool, disabled: PropTypes.bool,
onHeaderChange: PropTypes.func onHeaderChange: PropTypes.func,
scenarioKeys: PropTypes.arrayOf(PropTypes.string).isRequired
}; };
export default ColumnHeaderCheckbox; export default ColumnHeaderCheckbox;

View File

@@ -12,12 +12,13 @@ import {
UPDATE_NOTIFICATION_SETTINGS, UPDATE_NOTIFICATION_SETTINGS,
UPDATE_NOTIFICATIONS_AUTOADD UPDATE_NOTIFICATIONS_AUTOADD
} from "../../graphql/user.queries.js"; } from "../../graphql/user.queries.js";
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js"; import { getNotificationScenarios, notificationScenarioDefaults } from "../../utils/jobNotificationScenarios.js";
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx"; import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx"; import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx";
import { useIsEmployee } from "../../utils/useIsEmployee.js"; import { useIsEmployee } from "../../utils/useIsEmployee.js";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
/** /**
* Notifications Settings Form * Notifications Settings Form
@@ -35,6 +36,7 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
const [initialAutoAdd, setInitialAutoAdd] = useState(false); const [initialAutoAdd, setInitialAutoAdd] = useState(false);
const notification = useNotification(); const notification = useNotification();
const isEmployee = useIsEmployee(bodyshop, currentUser); const isEmployee = useIsEmployee(bodyshop, currentUser);
const notificationScenarios = getNotificationScenarios({ includeEsign: hasDocumensoApiKey(bodyshop) });
// Fetch notification settings and notifications_autoadd // Fetch notification settings and notifications_autoadd
const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, { const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, {
@@ -55,7 +57,8 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
// Ensure each scenario has an object with { app, email, fcm } // Ensure each scenario has an object with { app, email, fcm }
const formattedValues = notificationScenarios.reduce((acc, scenario) => { const formattedValues = notificationScenarios.reduce((acc, scenario) => {
acc[scenario] = settings[scenario] ?? { app: false, email: false, fcm: false }; acc[scenario] = settings[scenario] ??
notificationScenarioDefaults[scenario] ?? { app: false, email: false, fcm: false };
return acc; return acc;
}, {}); }, {});
@@ -65,7 +68,7 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
setInitialAutoAdd(autoAdd); setInitialAutoAdd(autoAdd);
setIsDirty(false); // Reset dirty state when new data loads setIsDirty(false); // Reset dirty state when new data loads
} }
}, [data, form]); }, [data, form, notificationScenarios]);
// Handle toggle of notifications_autoadd // Handle toggle of notifications_autoadd
const handleAutoAddToggle = async (checked) => { const handleAutoAddToggle = async (checked) => {
@@ -136,7 +139,14 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
width: "80%" width: "80%"
}, },
{ {
title: <ColumnHeaderCheckbox channel="app" form={form} onHeaderChange={() => setIsDirty(true)} />, title: (
<ColumnHeaderCheckbox
channel="app"
form={form}
onHeaderChange={() => setIsDirty(true)}
scenarioKeys={notificationScenarios}
/>
),
dataIndex: "app", dataIndex: "app",
key: "app", key: "app",
align: "center", align: "center",
@@ -147,7 +157,14 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
) )
}, },
{ {
title: <ColumnHeaderCheckbox channel="email" form={form} onHeaderChange={() => setIsDirty(true)} />, title: (
<ColumnHeaderCheckbox
channel="email"
form={form}
onHeaderChange={() => setIsDirty(true)}
scenarioKeys={notificationScenarios}
/>
),
dataIndex: "email", dataIndex: "email",
key: "email", key: "email",
align: "center", align: "center",
@@ -162,7 +179,14 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
// Currently disabled for prod // Currently disabled for prod
if (!import.meta.env.PROD) { if (!import.meta.env.PROD) {
columns.push({ columns.push({
title: <ColumnHeaderCheckbox channel="fcm" form={form} onHeaderChange={() => setIsDirty(true)} />, title: (
<ColumnHeaderCheckbox
channel="fcm"
form={form}
onHeaderChange={() => setIsDirty(true)}
scenarioKeys={notificationScenarios}
/>
),
dataIndex: "fcm", dataIndex: "fcm",
key: "fcm", key: "fcm",
align: "center", align: "center",

View File

@@ -11,12 +11,13 @@ import ResponsiveTable from "../responsive-table/responsive-table.component";
export default function OwnersListComponent({ loading, owners, total, refetch }) { export default function OwnersListComponent({ loading, owners, total, refetch }) {
const search = queryString.parse(useLocation().search); const search = queryString.parse(useLocation().search);
const { const { page, pageSize } = search;
page
// sortcolumn, sortorder
} = search;
const history = useNavigate(); const history = useNavigate();
const currentPage = Number.parseInt(page || "1", 10);
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
const [state, setState] = useState({ const [state, setState] = useState({
sortedInfo: {}, sortedInfo: {},
filteredInfo: { text: "" } filteredInfo: { text: "" }
@@ -71,10 +72,14 @@ export default function OwnersListComponent({ loading, owners, total, refetch })
]; ];
const handleTableChange = (pagination, filters, sorter) => { const handleTableChange = (pagination, filters, sorter) => {
const nextPageSize = pagination?.pageSize || currentPageSize;
const pageSizeChanged = nextPageSize !== currentPageSize;
setState({ ...state, filteredInfo: filters, sortedInfo: sorter }); setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
const updatedSearch = { const updatedSearch = {
...search, ...search,
page: pagination.current, pageSize: nextPageSize,
page: pageSizeChanged ? 1 : pagination.current,
sortcolumn: sorter.columnKey, sortcolumn: sorter.columnKey,
sortorder: sorter.order sortorder: sorter.order
}; };
@@ -119,7 +124,7 @@ export default function OwnersListComponent({ loading, owners, total, refetch })
> >
<ResponsiveTable <ResponsiveTable
loading={loading} loading={loading}
pagination={{ placement: "top", pageSize: pageLimit, current: parseInt(page || 1, 10), total: total }} pagination={{ placement: "top", pageSize: currentPageSize, current: currentPage, showSizeChanger: true, total: total }}
columns={columns} columns={columns}
mobileColumnKeys={["name", "ownr_ph1", "ownr_ph2"]} mobileColumnKeys={["name", "ownr_ph1", "ownr_ph2"]}
rowKey="id" rowKey="id"

View File

@@ -8,14 +8,19 @@ import { pageLimit } from "../../utils/config";
export default function OwnersListContainer() { export default function OwnersListContainer() {
const searchParams = queryString.parse(useLocation().search); const searchParams = queryString.parse(useLocation().search);
const { page, sortcolumn, sortorder, search } = searchParams; const { page, sortcolumn, sortorder, search, pageSize } = searchParams;
const currentPage = Number.parseInt(page || "1", 10);
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
const { loading, error, data, refetch } = useQuery(QUERY_ALL_OWNERS_PAGINATED, { const { loading, error, data, refetch } = useQuery(QUERY_ALL_OWNERS_PAGINATED, {
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only", nextFetchPolicy: "network-only",
variables: { variables: {
search: search || "", search: search || "",
offset: page ? (page - 1) * pageLimit : 0, offset: (currentPage - 1) * currentPageSize,
limit: pageLimit, limit: currentPageSize,
order: [ order: [
{ {
[sortcolumn || "created_at"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc" [sortcolumn || "created_at"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc"

View File

@@ -1,5 +1,5 @@
import { DeleteFilled, DownOutlined, WarningFilled } from "@ant-design/icons"; import { DeleteFilled, DownOutlined, WarningFilled } from "@ant-design/icons";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd"; import { Button, Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
@@ -176,6 +176,9 @@ export function PartsOrderModalComponent({
> >
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item key={`${index}job_line_id`} name={[field.name, "job_line_id"]} hidden>
<Input type="hidden" />
</Form.Item>
<Form.Item <Form.Item
label={t("parts_orders.fields.line_remarks")} label={t("parts_orders.fields.line_remarks")}
key={`${index}line_remarks`} key={`${index}line_remarks`}

View File

@@ -19,10 +19,11 @@ import { TemplateList } from "../../utils/TemplateConstants";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import PartsOrderModalComponent from "./parts-order-modal.component"; import PartsOrderModalComponent from "./parts-order-modal.component";
import axios from "axios"; import axios from "axios";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import _ from "lodash"; import _ from "lodash";
import { UPDATE_JOB } from "../../graphql/jobs.queries"; import { UPDATE_JOB } from "../../graphql/jobs.queries";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { buildSubmittedPartsOrderLines, getSubmittedPartsOrderJobLineIds } from "./parts-order-modal.utils.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser, currentUser: selectCurrentUser,
@@ -82,15 +83,10 @@ export function PartsOrderModalContainer({
// Force job_line_id from context so it never gets dropped by AntD form submission behavior. // Force job_line_id from context so it never gets dropped by AntD form submission behavior.
const submittedLines = values?.parts_order_lines?.data ?? []; const submittedLines = values?.parts_order_lines?.data ?? [];
const forcedLines = submittedLines.map((p, index) => { const forcedLines = buildSubmittedPartsOrderLines({
const originalLine = linesToOrder?.[index]; submittedLines,
const jobLineId = isReturn ? originalLine?.joblineid : originalLine?.id; linesToOrder,
isReturn
return {
...p,
job_line_id: jobLineId,
...(isReturn && { cm_received: false })
};
}); });
let insertResult; let insertResult;
@@ -147,10 +143,7 @@ export function PartsOrderModalContainer({
type: isReturn ? "jobspartsreturn" : "jobspartsorder" type: isReturn ? "jobspartsreturn" : "jobspartsorder"
}); });
// Use linesToOrder from context instead of form values to preserve job line ids const jobLineIds = getSubmittedPartsOrderJobLineIds(forcedLines);
const jobLineIds = (linesToOrder ?? [])
.filter((line) => (isReturn ? line.joblineid : line.id))
.map((line) => (isReturn ? line.joblineid : line.id));
try { try {
const jobLinesResult = await updateJobLines({ const jobLinesResult = await updateJobLines({
@@ -206,23 +199,20 @@ export function PartsOrderModalContainer({
isinhouse: true, isinhouse: true,
date: dayjs(), date: dayjs(),
total: 0, total: 0,
billlines: forcedLines.map((p, index) => { billlines: forcedLines.map((p) => ({
const originalLine = linesToOrder?.[index]; joblineid: p.job_line_id,
return { actual_price: p.act_price,
joblineid: isReturn ? originalLine?.joblineid : originalLine?.id, actual_cost: 0, // p.act_price,
actual_price: p.act_price, line_desc: p.line_desc,
actual_cost: 0, // p.act_price, line_remarks: p.line_remarks,
line_desc: p.line_desc, part_type: p.part_type,
line_remarks: p.line_remarks, quantity: p.quantity || 1,
part_type: p.part_type, applicable_taxes: {
quantity: p.quantity || 1, local: false,
applicable_taxes: { state: false,
local: false, federal: false
state: false, }
federal: false }))
}
};
})
} }
} }
}); });

View File

@@ -0,0 +1,23 @@
export const getPartsOrderJobLineId = ({ line, originalLine, isReturn }) => {
return line?.job_line_id || (isReturn ? originalLine?.joblineid : originalLine?.id);
};
export const buildSubmittedPartsOrderLines = ({ submittedLines = [], linesToOrder = [], isReturn }) => {
return submittedLines.map((line, index) => {
const jobLineId = getPartsOrderJobLineId({
line,
originalLine: linesToOrder?.[index],
isReturn
});
return {
...line,
job_line_id: jobLineId,
...(isReturn && { cm_received: false })
};
});
};
export const getSubmittedPartsOrderJobLineIds = (partsOrderLines = []) => {
return partsOrderLines.map((line) => line.job_line_id).filter(Boolean);
};

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { buildSubmittedPartsOrderLines, getSubmittedPartsOrderJobLineIds } from "./parts-order-modal.utils.js";
describe("parts order modal utilities", () => {
it("preserves submitted job line ids after a row is removed", () => {
const submittedLines = [
{ line_desc: "second line", job_line_id: "job-line-2" },
{ line_desc: "third line", job_line_id: "job-line-3" }
];
const linesToOrder = [{ id: "job-line-1" }, { id: "job-line-2" }, { id: "job-line-3" }];
const result = buildSubmittedPartsOrderLines({ submittedLines, linesToOrder, isReturn: false });
expect(result.map((line) => line.job_line_id)).toEqual(["job-line-2", "job-line-3"]);
expect(getSubmittedPartsOrderJobLineIds(result)).toEqual(["job-line-2", "job-line-3"]);
});
it("falls back to original return line ids when the form omits hidden metadata", () => {
const submittedLines = [{ line_desc: "return line" }];
const linesToOrder = [{ joblineid: "return-job-line-1" }];
const result = buildSubmittedPartsOrderLines({ submittedLines, linesToOrder, isReturn: true });
expect(result).toEqual([
{
line_desc: "return line",
job_line_id: "return-job-line-1",
cm_received: false
}
]);
});
});

View File

@@ -29,7 +29,10 @@ const mapStateToProps = createStructuredSelector({
export function PartsQueueListComponent({ bodyshop }) { export function PartsQueueListComponent({ bodyshop }) {
const searchParams = queryString.parse(useLocation().search); const searchParams = queryString.parse(useLocation().search);
const { selected, sortcolumn, sortorder, statusFilters } = searchParams; const { selected, sortcolumn, sortorder, statusFilters, page, pageSize } = searchParams;
const currentPage = Number.parseInt(page || "1", 10);
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
const history = useNavigate(); const history = useNavigate();
const [filter, setFilter] = useLocalStorage("filter_parts_queue", null); const [filter, setFilter] = useLocalStorage("filter_parts_queue", null);
const [viewTimeStamp, setViewTimeStamp] = useLocalStorage("parts_queue_timestamps", false); const [viewTimeStamp, setViewTimeStamp] = useLocalStorage("parts_queue_timestamps", false);
@@ -66,7 +69,11 @@ export function PartsQueueListComponent({ bodyshop }) {
: []; : [];
const handleTableChange = (pagination, filters, sorter) => { const handleTableChange = (pagination, filters, sorter) => {
// searchParams.page = pagination.current; const nextPageSize = pagination?.pageSize || currentPageSize;
const pageSizeChanged = nextPageSize !== currentPageSize;
searchParams.pageSize = nextPageSize;
searchParams.page = pageSizeChanged ? 1 : pagination.current;
searchParams.sortcolumn = sorter.columnKey; searchParams.sortcolumn = sorter.columnKey;
searchParams.sortorder = sorter.order; searchParams.sortorder = sorter.order;
@@ -315,9 +322,10 @@ export function PartsQueueListComponent({ bodyshop }) {
loading={loading} loading={loading}
pagination={{ pagination={{
placement: "top", placement: "top",
pageSize: pageLimit pageSize: currentPageSize,
// current: parseInt(page || 1), current: currentPage,
// total: data && data.jobs_aggregate.aggregate.count, showSizeChanger: true,
total: jobs.length
}} }}
columns={columns} columns={columns}
mobileColumnKeys={["ro_number", "ownr_ln", "status", "vehicle", "partsstatus"]} mobileColumnKeys={["ro_number", "ownr_ln", "status", "vehicle", "partsstatus"]}

View File

@@ -1,4 +1,4 @@
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Form, Input, Radio, Select } from "antd"; import { Form, Input, Radio, Select } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";

View File

@@ -1,4 +1,4 @@
import { MailOutlined, PrinterOutlined } from "@ant-design/icons"; import { MailOutlined, PrinterOutlined, SignatureFilled } from "@ant-design/icons";
import { Space, Spin } from "antd"; import { Space, Spin } from "antd";
import { useState } from "react"; import { useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
@@ -10,6 +10,9 @@ import { GenerateDocument } from "../../utils/RenderTemplate";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component"; import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import { HasFeatureAccess } from "./../feature-wrapper/feature-wrapper.component"; import { HasFeatureAccess } from "./../feature-wrapper/feature-wrapper.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import axios from "axios";
import { setModalContext } from "../../redux/modals/modals.actions.js";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
printCenterModal: selectPrintCenter, printCenterModal: selectPrintCenter,
@@ -17,12 +20,29 @@ const mapStateToProps = createStructuredSelector({
technician: selectTechnician technician: selectTechnician
}); });
const mapDispatchToProps = () => ({}); const mapDispatchToProps = (dispatch) => ({
setEsignatureContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "esignature"
})
)
});
export function PrintCenterItemComponent({ printCenterModal, item, id, bodyshop, disabled, technician }) { export function PrintCenterItemComponent({
printCenterModal,
setEsignatureContext,
item,
id,
bodyshop,
disabled,
technician
}) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { context } = printCenterModal; const { context } = printCenterModal;
const notification = useNotification(); const notification = useNotification();
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
const renderToNewWindow = async () => { const renderToNewWindow = async () => {
setLoading(true); setLoading(true);
@@ -39,6 +59,30 @@ export function PrintCenterItemComponent({ printCenterModal, item, id, bodyshop,
setLoading(false); setLoading(false);
}; };
const esignatureGenerate = async () => {
setLoading(true);
try {
const {
data: { token, documentId, envelopeId }
} = await axios.post("/esign/new", {
name: item.key,
jobid: id,
context,
bodyshop,
templateObject: {
name: item.key,
variables: { id: id }
}
});
setEsignatureContext({ context: { token, documentId, envelopeId, jobid: id } });
} catch (error) {
console.log(error);
} finally {
setLoading(false);
}
};
if ( if (
disabled || disabled ||
(item.featureNameRestricted && !HasFeatureAccess({ featureName: item.featureNameRestricted, bodyshop })) (item.featureNameRestricted && !HasFeatureAccess({ featureName: item.featureNameRestricted, bodyshop }))
@@ -54,6 +98,7 @@ export function PrintCenterItemComponent({ printCenterModal, item, id, bodyshop,
<li> <li>
<Space wrap> <Space wrap>
{item.title} {item.title}
{esignatureEnabled && <SignatureFilled onClick={esignatureGenerate} />}
<PrinterOutlined onClick={renderToNewWindow} /> <PrinterOutlined onClick={renderToNewWindow} />
{!technician ? ( {!technician ? (
<MailOutlined <MailOutlined

View File

@@ -1,4 +1,4 @@
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Card, Col, Row, Space, Tooltip, Typography } from "antd"; import { Button, Card, Col, Row, Space, Tooltip, Typography } from "antd";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";

View File

@@ -1,5 +1,6 @@
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Card, Col, Input, Row, Space, Typography } from "antd"; import { CloseOutlined } from "@ant-design/icons";
import { Alert, Button, Card, Col, Input, Row, Space, Typography, Tooltip } from "antd";
import _ from "lodash"; import _ from "lodash";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -9,11 +10,15 @@ import { selectPrintCenter } from "../../redux/modals/modals.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import { TemplateList } from "../../utils/TemplateConstants"; import { TemplateList } from "../../utils/TemplateConstants";
import Jobd3RdPartyModal from "../job-3rd-party-modal/job-3rd-party-modal.component"; import Jobd3RdPartyModal from "../job-3rd-party-modal/job-3rd-party-modal.component";
import EsignatureCustomDocument from "../esignature-custom-document/esignature-custom-document.component";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import PrintCenterItem from "../print-center-item/print-center-item.component"; import PrintCenterItem from "../print-center-item/print-center-item.component";
import PrintCenterJobsLabels from "../print-center-jobs-labels/print-center-jobs-labels.component"; import PrintCenterJobsLabels from "../print-center-jobs-labels/print-center-jobs-labels.component";
import PrintCenterSpeedPrint from "../print-center-speed-print/print-center-speed-print.component"; import PrintCenterSpeedPrint from "../print-center-speed-print/print-center-speed-print.component";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils"; import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import { selectTechnician } from "../../redux/tech/tech.selectors"; import { selectTechnician } from "../../redux/tech/tech.selectors";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
import useLocalStorage from "../../utils/useLocalStorage";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
printCenterModal: selectPrintCenter, printCenterModal: selectPrintCenter,
@@ -25,6 +30,10 @@ const mapDispatchToProps = () => ({});
export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technician }) { export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technician }) {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [esignatureBannerDismissed, setEsignatureBannerDismissed] = useLocalStorage(
"print_center_esignature_banner_dismissed",
false
);
const { id: jobId, job } = printCenterModal.context; const { id: jobId, job } = printCenterModal.context;
const tempList = TemplateList("job", {}); const tempList = TemplateList("job", {});
const { t } = useTranslation(); const { t } = useTranslation();
@@ -36,6 +45,10 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
splitKey: bodyshop.imexshopid splitKey: bodyshop.imexshopid
}); });
const hasDMSKey = bodyshopHasDmsKey(bodyshop); const hasDMSKey = bodyshopHasDmsKey(bodyshop);
const dmsMode = getDmsMode(bodyshop, "off");
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
const showEsignatureBanner = !esignatureEnabled && !esignatureBannerDismissed;
const Templates = !hasDMSKey const Templates = !hasDMSKey
? Object.keys(tempList) ? Object.keys(tempList)
@@ -45,7 +58,7 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
.filter( .filter(
(temp) => (temp) =>
(!temp.regions || (!temp.regions ||
(temp.regions && temp.regions[bodyshop.region_config]) || temp.regions?.[bodyshop.region_config] ||
(temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)) && (temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)) &&
(!temp.dms || temp.dms === false) (!temp.dms || temp.dms === false)
) )
@@ -57,9 +70,10 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
.filter( .filter(
(temp) => (temp) =>
!temp.regions || !temp.regions ||
(temp.regions && temp.regions[bodyshop.region_config]) || temp.regions?.[bodyshop.region_config] ||
(temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true) (temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)
) )
.filter((temp) => !isReynoldsMode || !temp.excludedDmsModes?.includes(dmsMode))
.filter((temp) => !technician || temp.group !== "financial"); .filter((temp) => !technician || temp.group !== "financial");
const JobsReportsList = const JobsReportsList =
@@ -85,6 +99,23 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
return ( return (
<div> <div>
{showEsignatureBanner && (
<Alert
action={
<Button
aria-label={t("general.actions.close")}
icon={<CloseOutlined />}
onClick={() => setEsignatureBannerDismissed(true)}
size="small"
type="text"
/>
}
banner
title={t("printcenter.banners.esignature_promo")}
type="info"
className="print-center-esignature-banner"
/>
)}
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col lg={8} md={12} sm={24}> <Col lg={8} md={12} sm={24}>
<PrintCenterSpeedPrint jobId={jobId} /> <PrintCenterSpeedPrint jobId={jobId} />
@@ -94,6 +125,13 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
extra={ extra={
<Space wrap> <Space wrap>
<PrintCenterJobsLabels jobId={jobId} /> <PrintCenterJobsLabels jobId={jobId} />
<Tooltip title={!esignatureEnabled ? t("esignature.tooltips.contact_sales") : null}>
<span>
<LockWrapperComponent locked={!esignatureEnabled} bodyshop={bodyshop}>
<EsignatureCustomDocument jobId={jobId} showUnavailable />
</LockWrapperComponent>
</span>
</Tooltip>
<Jobd3RdPartyModal jobId={jobId} job={job} /> <Jobd3RdPartyModal jobId={jobId} job={job} />
<Input.Search onChange={(e) => setSearch(e.target.value)} value={search} enterButton /> <Input.Search onChange={(e) => setSearch(e.target.value)} value={search} enterButton />
</Space> </Space>

View File

@@ -5,3 +5,7 @@
padding: 0; padding: 0;
} }
} }
.print-center-esignature-banner {
margin-bottom: 16px;
}

View File

@@ -80,14 +80,14 @@ const ModelInfoToolTip = ({ metadata, cardSettings }) =>
<Col span={24}> <Col span={24}>
<EllipsesToolTip <EllipsesToolTip
title={ title={
metadata.v_model_yr || metadata.v_make_desc || metadata.v_model_desc metadata.v_model_yr || metadata.v_make_desc || metadata.v_model_desc || metadata.v_color
? `${metadata.v_model_yr || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}` ? `${metadata.v_model_yr || ""} ${metadata.v_color || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}`
: null : null
} }
kiosk={cardSettings.kiosk} kiosk={cardSettings.kiosk}
> >
{metadata.v_model_yr || metadata.v_make_desc || metadata.v_model_desc ? ( {metadata.v_model_yr || metadata.v_make_desc || metadata.v_model_desc || metadata.v_color ? (
`${metadata.v_model_yr || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}` `${metadata.v_model_yr || ""} ${metadata.v_color || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}`
) : ( ) : (
<span>&nbsp;</span> <span>&nbsp;</span>
)} )}
@@ -431,6 +431,7 @@ export default function ProductionBoardCard({ technician, card, bodyshop, cardSe
<Card <Card
className={`react-trello-card ${cardSettings.kiosk ? "kiosk-mode" : ""}`} className={`react-trello-card ${cardSettings.kiosk ? "kiosk-mode" : ""}`}
size="small" size="small"
styles={{ header: { backgroundColor: "var(--card-bg-fallback)" } }}
style={{ style={{
backgroundColor: cardSettings?.cardcolor backgroundColor: cardSettings?.cardcolor
? bgColor.fallback || `rgba(${bgColor.r},${bgColor.g},${bgColor.b},${bgColor.a || 1})` ? bgColor.fallback || `rgba(${bgColor.r},${bgColor.g},${bgColor.b},${bgColor.a || 1})`

View File

@@ -11,7 +11,7 @@ import {
import { QUERY_KANBAN_SETTINGS } from "../../graphql/user.queries"; import { QUERY_KANBAN_SETTINGS } from "../../graphql/user.queries";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import ProductionBoardKanbanComponent from "./production-board-kanban.component"; import ProductionBoardKanbanComponent from "./production-board-kanban.component";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({

View File

@@ -28,11 +28,14 @@ const ProductionStatistics = ({ data, cardSettings, reducerData }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const calculateTotal = (items, key, subKey) => { const calculateTotal = (items, key, subKey) => {
return items.reduce((acc, item) => acc + (item[key]?.aggregate?.sum?.[subKey] || 0), 0); return items.reduce((acc, item) => acc + (item?.[key]?.aggregate?.sum?.[subKey] ?? 0), 0);
}; };
const calculateTotalAmount = (items, key) => { const calculateTotalAmount = (items, key) => {
return items.reduce((acc, item) => acc.add(Dinero(item[key]?.totals?.subtotal ?? Dinero())), Dinero({ amount: 0 })); return items.reduce(
(acc, item) => acc.add(Dinero(item?.[key]?.totals?.subtotal ?? Dinero())),
Dinero({ amount: 0 })
);
}; };
const calculateReducerTotalAmount = (lanes, key) => { const calculateReducerTotalAmount = (lanes, key) => {
@@ -67,58 +70,83 @@ const ProductionStatistics = ({ data, cardSettings, reducerData }) => {
return value; return value;
}; };
const filteredData = cardSettings.excludeSuspended === true ? data.filter((item) => item.suspended !== true) : data;
const filteredReducerData =
cardSettings.excludeSuspended === true
? {
...reducerData,
lanes: reducerData.lanes.map((lane) => ({
...lane,
cards: lane.cards.filter((card) => card.metadata.suspended !== true)
}))
}
: reducerData;
const totalHrs = cardSettings.totalHrs const totalHrs = cardSettings.totalHrs
? parseFloat((calculateTotal(data, "labhrs", "mod_lb_hrs") + calculateTotal(data, "larhrs", "mod_lb_hrs")).toFixed(2)) ? parseFloat(
(
calculateTotal(filteredData, "labhrs", "mod_lb_hrs") + calculateTotal(filteredData, "larhrs", "mod_lb_hrs")
).toFixed(2)
)
: null; : null;
const totalLAB = cardSettings.totalLAB const totalLAB = cardSettings.totalLAB
? parseFloat(calculateTotal(data, "labhrs", "mod_lb_hrs").toFixed(2)) ? parseFloat(calculateTotal(filteredData, "labhrs", "mod_lb_hrs").toFixed(2))
: null; : null;
const totalLAR = cardSettings.totalLAR const totalLAR = cardSettings.totalLAR
? parseFloat(calculateTotal(data, "larhrs", "mod_lb_hrs").toFixed(2)) ? parseFloat(calculateTotal(filteredData, "larhrs", "mod_lb_hrs").toFixed(2))
: null; : null;
const jobsInProduction = cardSettings.jobsInProduction ? data.length : null; const jobsInProduction = cardSettings.jobsInProduction ? filteredData.length : null;
const totalAmountInProduction = cardSettings.totalAmountInProduction const totalAmountInProduction = cardSettings.totalAmountInProduction
? calculateTotalAmount(data, "job_totals").toFormat("$0,0.00") ? calculateTotalAmount(filteredData, "job_totals").toFormat("$0,0.00")
: null; : null;
const totalAmountOnBoard = reducerData && cardSettings.totalAmountOnBoard const totalAmountOnBoard =
? calculateReducerTotalAmount(reducerData.lanes, "job_totals").toFormat("$0,0.00") filteredReducerData && cardSettings.totalAmountOnBoard
: null; ? calculateReducerTotalAmount(filteredReducerData.lanes, "job_totals").toFormat("$0,0.00")
: null;
const totalHrsOnBoard = reducerData && cardSettings.totalHrsOnBoard const totalHrsOnBoard =
? parseFloat(( filteredReducerData && cardSettings.totalHrsOnBoard
calculateReducerTotal(reducerData.lanes, "labhrs", "mod_lb_hrs") + ? parseFloat(
calculateReducerTotal(reducerData.lanes, "larhrs", "mod_lb_hrs") (
).toFixed(2)) calculateReducerTotal(filteredReducerData.lanes, "labhrs", "mod_lb_hrs") +
: null; calculateReducerTotal(filteredReducerData.lanes, "larhrs", "mod_lb_hrs")
).toFixed(2)
)
: null;
const totalLABOnBoard = reducerData && cardSettings.totalLABOnBoard const totalLABOnBoard =
? parseFloat(calculateReducerTotal(reducerData.lanes, "labhrs", "mod_lb_hrs").toFixed(2)) filteredReducerData && cardSettings.totalLABOnBoard
: null; ? parseFloat(calculateReducerTotal(filteredReducerData.lanes, "labhrs", "mod_lb_hrs").toFixed(2))
: null;
const totalLAROnBoard = reducerData && cardSettings.totalLAROnBoard const totalLAROnBoard =
? parseFloat(calculateReducerTotal(reducerData.lanes, "larhrs", "mod_lb_hrs").toFixed(2)) filteredReducerData && cardSettings.totalLAROnBoard
: null; ? parseFloat(calculateReducerTotal(filteredReducerData.lanes, "larhrs", "mod_lb_hrs").toFixed(2))
: null;
const jobsOnBoard = reducerData && cardSettings.jobsOnBoard const jobsOnBoard =
? reducerData.lanes.reduce((acc, lane) => acc + lane.cards.length, 0) filteredReducerData && cardSettings.jobsOnBoard
: null; ? filteredReducerData.lanes.reduce((acc, lane) => acc + lane.cards.length, 0)
: null;
const tasksInProduction = cardSettings.tasksInProduction const tasksInProduction = cardSettings.tasksInProduction
? data.reduce((acc, item) => acc + (item.tasks_aggregate?.aggregate?.count || 0), 0) ? filteredData.reduce((acc, item) => acc + (item.tasks_aggregate?.aggregate?.count || 0), 0)
: null; : null;
const tasksOnBoard = reducerData && cardSettings.tasksOnBoard const tasksOnBoard =
? reducerData.lanes.reduce((acc, lane) => { filteredReducerData && cardSettings.tasksOnBoard
return ( ? filteredReducerData.lanes.reduce((acc, lane) => {
acc + lane.cards.reduce((laneAcc, card) => laneAcc + (card.metadata.tasks_aggregate?.aggregate?.count || 0), 0) return (
); acc +
}, 0) lane.cards.reduce((laneAcc, card) => laneAcc + (card.metadata.tasks_aggregate?.aggregate?.count || 0), 0)
: null; );
}, 0)
: null;
const statistics = mergeStatistics(statisticsItems, [ const statistics = mergeStatistics(statisticsItems, [
{ id: 0, value: totalHrs, type: StatisticType.HOURS }, { id: 0, value: totalHrs, type: StatisticType.HOURS },

View File

@@ -14,7 +14,16 @@ const StatisticsSettings = ({ t, statisticsOrder, setStatisticsOrder, setHasChan
}; };
return ( return (
<Card title={t("production.settings.statistics_title")}> <Card
title={t("production.settings.statistics_title")}
extra={
<div style={{ display: "flex", alignItems: "center" }}>
<Form.Item name="excludeSuspended" valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox>{t("production.settings.statistics.exclude_suspended")}</Checkbox>
</Form.Item>
</div>
}
>
<DragDropContext onDragEnd={onDragEnd}> <DragDropContext onDragEnd={onDragEnd}>
<Droppable direction="grid" droppableId="statistics"> <Droppable direction="grid" droppableId="statistics">
{(provided) => ( {(provided) => (

View File

@@ -91,7 +91,8 @@ const defaultKanbanSettings = {
subtotal: false, subtotal: false,
statisticsOrder: statisticsItems.map((item) => item.id), statisticsOrder: statisticsItems.map((item) => item.id),
selectedMdInsCos: [], selectedMdInsCos: [],
selectedEstimators: [] selectedEstimators: [],
excludeSuspended: false
}; };
const defaultFilters = { search: "", employeeId: null, alert: false }; const defaultFilters = { search: "", employeeId: null, alert: false };

View File

@@ -5,7 +5,7 @@ import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectTechnician } from "../../redux/tech/tech.selectors"; import { selectTechnician } from "../../redux/tech/tech.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
technician: selectTechnician, technician: selectTechnician,

View File

@@ -140,13 +140,11 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
sortOrder: state.sortedInfo.columnKey === "vehicle" && state.sortedInfo.order, sortOrder: state.sortedInfo.columnKey === "vehicle" && state.sortedInfo.order,
render: (text, record) => render: (text, record) =>
technician ? ( technician ? (
<>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""} ${ <>{`${record.v_model_yr || ""} ${record.v_color || ""}${record.v_make_desc || ""} ${record.v_model_desc || ""} ${record.plate_no || ""}`}</>
record.v_color || ""
} ${record.plate_no || ""}`}</>
) : ( ) : (
<Link to={`/manage/vehicles/${record.vehicleid}`}>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${ <Link
record.v_model_desc || "" to={`/manage/vehicles/${record.vehicleid}`}
} ${record.v_color || ""} ${record.plate_no || ""}`}</Link> >{`${record.v_model_yr || ""} ${record.v_color || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""} ${record.plate_no || ""}`}</Link>
) )
}, },
{ {
@@ -621,7 +619,7 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
sortOrder: state.sortedInfo.columnKey === "dms_id" && state.sortedInfo.order sortOrder: state.sortedInfo.columnKey === "dms_id" && state.sortedInfo.order
} }
] ]
: []), : [])
]; ];
}; };
export default productionListColumnsData; export default productionListColumnsData;

View File

@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { UPDATE_ACTIVE_PROD_LIST_VIEW } from "../../graphql/associations.queries"; import { UPDATE_ACTIVE_PROD_LIST_VIEW } from "../../graphql/associations.queries";
import { UPDATE_SHOP } from "../../graphql/bodyshop.queries"; import { UPDATE_SHOP } from "../../graphql/bodyshop.queries";
import ProductionListColumns from "../production-list-columns/production-list-columns.data"; import ProductionListColumns from "../production-list-columns/production-list-columns.data";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
import { isFunction } from "lodash"; import { isFunction } from "lodash";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";

View File

@@ -1,6 +1,6 @@
import { HolderOutlined, SyncOutlined } from "@ant-design/icons"; import { HolderOutlined, SyncOutlined } from "@ant-design/icons";
import { PageHeader } from "@ant-design/pro-layout"; import { PageHeader } from "@ant-design/pro-layout";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Dropdown, Input, Space, Statistic, Table } from "antd"; import { Button, Dropdown, Input, Space, Statistic, Table } from "antd";
import _ from "lodash"; import _ from "lodash";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";

View File

@@ -9,7 +9,7 @@ import {
} from "../../graphql/jobs.queries"; } from "../../graphql/jobs.queries";
import ProductionListTable from "./production-list-table.component"; import ProductionListTable from "./production-list-table.component";
import _ from "lodash"; import _ from "lodash";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import { useSocket } from "../../contexts/SocketIO/useSocket.js";
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) { export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {

View File

@@ -1,5 +1,5 @@
import { useLazyQuery } from "@apollo/client/react"; import { useLazyQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Card, Col, DatePicker, Form, Input, Radio, Row, Typography } from "antd"; import { Button, Card, Col, DatePicker, Form, Input, Radio, Row, Typography } from "antd";
import _ from "lodash"; import _ from "lodash";
import { useState } from "react"; import { useState } from "react";
@@ -12,6 +12,7 @@ import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries";
import { selectReportCenter } from "../../redux/modals/modals.selectors"; import { selectReportCenter } from "../../redux/modals/modals.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import DatePickerRanges from "../../utils/DatePickerRanges"; import DatePickerRanges from "../../utils/DatePickerRanges";
import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import { GenerateDocument } from "../../utils/RenderTemplate"; import { GenerateDocument } from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants"; import { TemplateList } from "../../utils/TemplateConstants";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
@@ -48,12 +49,18 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const Templates = TemplateList("report_center"); const Templates = TemplateList("report_center");
const dmsMode = getDmsMode(bodyshop, "off");
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
const ReportsList = Object.keys(Templates) const ReportsList = Object.keys(Templates)
.map((key) => Templates[key]) .map((key) => Templates[key])
.filter((temp) => { .filter((temp) => {
const enhancedPayrollOn = Enhanced_Payroll.treatment === "on"; const enhancedPayrollOn = Enhanced_Payroll.treatment === "on";
const adpPayrollOn = ADPPayroll.treatment === "on"; const adpPayrollOn = ADPPayroll.treatment === "on";
if (isReynoldsMode && temp.excludedDmsModes?.includes(dmsMode)) {
return false;
}
if (enhancedPayrollOn && adpPayrollOn) { if (enhancedPayrollOn && adpPayrollOn) {
return temp.enhanced_payroll !== false || temp.adp_payroll !== false; return temp.enhanced_payroll !== false || temp.adp_payroll !== false;
} }
@@ -408,6 +415,6 @@ const restrictedReports = [
{ 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", days: 736 },
{ key: "customer_list_excel", days: 183 } { key: "customer_list_excel", days: 736 }
]; ];

View File

@@ -48,7 +48,7 @@ export function ScheduleVerifyIntegrity({ currentUser }) {
setLoading(false); setLoading(false);
}; };
if (currentUser.email === "patrick@imex.prod") if (currentUser.email === "allan@imex.prod" || currentUser.email === "dave@imex.prod")
return ( return (
<Button loading={loading} onClick={handleVerify}> <Button loading={loading} onClick={handleVerify}>
Developer Use Only - Verify Schedule Integrity Developer Use Only - Verify Schedule Integrity

View File

@@ -1,10 +1,10 @@
import { DeleteFilled } from "@ant-design/icons"; import { DeleteFilled } from "@ant-design/icons";
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react"; import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd"; import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component"; import ResponsiveTable from "../responsive-table/responsive-table.component";
import queryString from "query-string"; import queryString from "query-string";
import { useCallback, useEffect, useState } from "react"; import { useCallback, 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 { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
@@ -12,11 +12,11 @@ import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
import { import {
CHECK_EMPLOYEE_EMAIL,
CHECK_EMPLOYEE_NUMBER, CHECK_EMPLOYEE_NUMBER,
DELETE_VACATION, DELETE_VACATION,
INSERT_EMPLOYEES, INSERT_EMPLOYEES,
QUERY_EMPLOYEE_BY_ID, QUERY_EMPLOYEE_BY_ID,
QUERY_USERS_BY_EMAIL,
UPDATE_EMPLOYEE UPDATE_EMPLOYEE
} from "../../graphql/employees.queries"; } from "../../graphql/employees.queries";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
@@ -52,6 +52,7 @@ const mapDispatchToProps = () => ({
}); });
export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDirty }) { export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDirty }) {
const submitActionRef = useRef("save");
const { t } = useTranslation(); const { t } = useTranslation();
const [internalIsDirty, setInternalIsDirty] = useState(false); const [internalIsDirty, setInternalIsDirty] = useState(false);
const resolvedIsDirty = typeof isDirty === "boolean" ? isDirty : internalIsDirty; const resolvedIsDirty = typeof isDirty === "boolean" ? isDirty : internalIsDirty;
@@ -128,55 +129,118 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
}); });
}, [clearEmployeeFormMeta, currentEmployeeData, form]); }, [clearEmployeeFormMeta, currentEmployeeData, form]);
const syncEmployeeFormToSavedData = useCallback(
(employeeData) => {
if (employeeData) {
form.setFieldsValue(employeeData);
}
window.requestAnimationFrame(() => {
clearEmployeeFormMeta();
});
},
[clearEmployeeFormMeta, form]
);
useEffect(() => { useEffect(() => {
resetEmployeeFormToCurrentData(); resetEmployeeFormToCurrentData();
}, [resetEmployeeFormToCurrentData, search.employeeId]); }, [resetEmployeeFormToCurrentData, search.employeeId]);
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE); const [updateEmployee] = useMutation(UPDATE_EMPLOYEE);
const [insertEmployees] = useMutation(INSERT_EMPLOYEES); const [insertEmployees] = useMutation(INSERT_EMPLOYEES);
const saveAndResetSubmitAction = useCallback(() => {
const submitAction = submitActionRef.current;
submitActionRef.current = "save";
return submitAction;
}, []);
const submitEmployeeForm = useCallback(
(submitAction = "save") => {
submitActionRef.current = submitAction;
form.submit();
},
[form]
);
const navigateToEmployee = useCallback(
(employeeId) => {
history({
search: queryString.stringify({
...search,
employeeId
})
});
},
[history, search]
);
const handleFinish = async (values) => {
const submitAction = saveAndResetSubmitAction();
const userEmail = typeof values.user_email === "string" ? values.user_email.trim() : values.user_email;
const normalizedValues = {
...values,
user_email: userEmail === "" ? null : userEmail
};
const handleFinish = (values) => {
if (search.employeeId && search.employeeId !== "new") { if (search.employeeId && search.employeeId !== "new") {
//Update a record. //Update a record.
logImEXEvent("shop_employee_update"); logImEXEvent("shop_employee_update");
updateEmployee({ try {
variables: { const result = await updateEmployee({
id: search.employeeId, variables: {
employee: { id: search.employeeId,
...values, employee: normalizedValues
user_email: values.user_email === "" ? null : values.user_email
} }
}
})
.then(() => {
updateDirtyState(false);
void refetch();
notification.success({
title: t("employees.successes.save")
});
})
.catch((error) => {
notification.error({
title: t("employees.errors.save", {
message: JSON.stringify(error)
})
});
}); });
} else {
//New record, insert it.
logImEXEvent("shop_employee_insert");
insertEmployees({ syncEmployeeFormToSavedData(result?.data?.update_employees?.returning?.[0] ?? normalizedValues);
variables: { employees: [{ ...values, shopid: bodyshop.id }] }, void refetch();
refetchQueries: ["QUERY_EMPLOYEES"] if (submitAction === "saveAndNew") {
}).then((r) => { navigateToEmployee("new");
updateDirtyState(false); }
search.employeeId = r.data.insert_employees.returning[0].id;
history({ search: queryString.stringify(search) });
notification.success({ notification.success({
title: t("employees.successes.save") title: t("employees.successes.save")
}); });
} catch (error) {
notification.error({
title: t("employees.errors.save", {
message: JSON.stringify(error)
})
});
}
return;
}
//New record, insert it.
logImEXEvent("shop_employee_insert");
try {
const result = await insertEmployees({
variables: { employees: [{ ...normalizedValues, shopid: bodyshop.id }] },
refetchQueries: ["QUERY_EMPLOYEES"]
});
const savedEmployee = result?.data?.insert_employees?.returning?.[0];
if (submitAction === "saveAndNew") {
if (isNewEmployee) {
resetEmployeeFormToCurrentData();
}
navigateToEmployee("new");
} else if (savedEmployee?.id) {
syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues);
navigateToEmployee(savedEmployee.id);
} else {
syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues);
}
notification.success({
title: t("employees.successes.save")
});
} catch (error) {
notification.error({
title: t("employees.errors.save", {
message: JSON.stringify(error)
})
}); });
} }
}; };
@@ -240,13 +304,24 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
<Card <Card
title={employeeCardTitle} title={employeeCardTitle}
extra={ extra={
<Button type="primary" onClick={() => form.submit()} disabled={!resolvedIsDirty} style={{ minWidth: 170 }}> <Space wrap>
{t("employees.actions.save_employee")} <Button onClick={() => submitEmployeeForm("saveAndNew")} disabled={!resolvedIsDirty} style={{ minWidth: 170 }}>
</Button> {t("general.actions.saveandnew") || "Save and New"}
</Button>
<Button
type="primary"
onClick={() => submitEmployeeForm("save")}
disabled={!resolvedIsDirty}
style={{ minWidth: 170 }}
>
{t("employees.actions.save_employee")}
</Button>
</Space>
} }
> >
<Form <Form
onFinish={handleFinish} onFinish={handleFinish}
onFinishFailed={saveAndResetSubmitAction}
autoComplete={"off"} autoComplete={"off"}
layout="vertical" layout="vertical"
form={form} form={form}
@@ -417,18 +492,29 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
rules={[ rules={[
({ getFieldValue }) => ({ ({ getFieldValue }) => ({
async validator(rule, value) { async validator(rule, value) {
const user_email = getFieldValue("user_email"); const user_email = typeof value === "string" ? value.trim() : getFieldValue("user_email");
if (user_email && value) { if (user_email && value) {
const response = await client.query({ const response = await client.query({
query: QUERY_USERS_BY_EMAIL, query: CHECK_EMPLOYEE_EMAIL,
variables: { variables: {
email: user_email email: user_email,
shopId: bodyshop.id
} }
}); });
if (response.data.users.length === 1) { if (response.data.users.length === 1) {
return Promise.resolve(); const matchingEmployees = response.data.employees_aggregate.nodes;
const currentEmployeeId = form.getFieldValue("id") ?? search.employeeId;
if (
response.data.employees_aggregate.aggregate.count === 0 ||
matchingEmployees.every((employee) => employee.id === currentEmployeeId)
) {
return Promise.resolve();
}
return Promise.reject(t("employees.validation.unique_user_email"));
} }
return Promise.reject(t("bodyshop.validation.useremailmustexist")); return Promise.reject(t("bodyshop.validation.useremailmustexist"));
} else { } else {

View File

@@ -0,0 +1,422 @@
import { useApolloClient } from "@apollo/client/react";
import { Form } from "antd";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useEffect } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
CHECK_EMPLOYEE_EMAIL,
DELETE_VACATION,
INSERT_EMPLOYEES,
QUERY_EMPLOYEE_BY_ID,
UPDATE_EMPLOYEE
} from "../../graphql/employees.queries";
import { ShopEmployeesFormComponent } from "./shop-employees-form.component.jsx";
const insertEmployeesMock = vi.fn();
const updateEmployeeMock = vi.fn();
const deleteVacationMock = vi.fn();
const useQueryMock = vi.fn();
const useMutationMock = vi.fn();
const apolloClientQueryMock = vi.fn();
const navigateMock = vi.fn();
const notification = {
error: vi.fn(),
success: vi.fn()
};
vi.mock("@apollo/client/react", async () => {
const actual = await vi.importActual("@apollo/client/react");
return {
...actual,
useApolloClient: vi.fn(),
useQuery: (...args) => useQueryMock(...args),
useMutation: (...args) => useMutationMock(...args)
};
});
vi.mock("../../feature-flags/splitio-react-replacement", () => ({
useTreatmentsWithConfig: () => ({
treatments: {
Enhanced_Payroll: {
treatment: "off"
}
}
})
}));
vi.mock("react-router-dom", () => ({
useLocation: () => ({
search: "?employeeId=new"
}),
useNavigate: () => navigateMock
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key, values = {}) => {
const translations = {
"bodyshop.labels.employee_options": "Employee Options",
"bodyshop.labels.employee_rates": "Employee Rates",
"bodyshop.labels.employee_vacation": "Employee Vacation",
"bodyshop.labels.employees": "Employees",
"employees.actions.addrate": "Add Rate",
"employees.actions.addvacation": "Add Vacation",
"employees.actions.new": "New Employee",
"employees.actions.save_employee": "Save Employee",
"employees.fields.active": "Active",
"employees.fields.employee_number": "Employee Number",
"employees.fields.external_id": "External Id",
"employees.fields.first_name": "First Name",
"employees.fields.flat_rate": "Flat Rate",
"employees.fields.hire_date": "Hire Date",
"employees.fields.last_name": "Last Name",
"employees.fields.pin": "PIN",
"employees.fields.rate": "Rate",
"employees.fields.termination_date": "Termination Date",
"employees.fields.user_email": "User Email",
"employees.labels.active": "Active",
"employees.successes.save": "Saved",
"general.actions.saveandnew": "Save and New",
"general.labels.actions": "Actions"
};
if (key === "employees.errors.save") {
return `Save failed: ${values.message ?? ""}`;
}
if (key === "employees.validation.unique_employee_number") {
return "Employee number must be unique";
}
if (key === "employees.validation.unique_user_email") {
return "User email already assigned";
}
if (key === "bodyshop.validation.useremailmustexist") {
return "User email must exist";
}
return translations[key] || key;
}
})
}));
vi.mock("../../contexts/Notifications/notificationContext.jsx", () => ({
useNotification: () => notification
}));
vi.mock("../../firebase/firebase.utils", () => ({
logImEXEvent: vi.fn()
}));
vi.mock("../alert/alert.component", () => ({
default: ({ title }) => <div>{title}</div>
}));
vi.mock("../form-fields-changed-alert/form-fields-changed-alert.component.jsx", () => ({
default: () => null
}));
vi.mock("../form-date-time-picker/form-date-time-picker.component.jsx", () => ({
default: ({ id, value, onChange }) => (
<input id={id} type="text" value={value ?? ""} onChange={(event) => onChange?.(event.target.value)} />
)
}));
vi.mock("../form-items-formatted/email-form-item.component.jsx", () => ({
default: ({ id, value, onChange }) => (
<input id={id} type="email" value={value ?? ""} onChange={(event) => onChange?.(event.target.value)} />
)
}));
vi.mock("../layout-form-row/layout-form-row.component", () => ({
default: ({ title, extra, actions, children }) => (
<div>
{title}
{extra}
{children}
{actions}
</div>
)
}));
vi.mock("../layout-form-row/inline-validated-form-row.component.jsx", () => ({
default: ({ title, extra, children }) => (
<div>
{title}
{extra}
{children}
</div>
)
}));
vi.mock("../layout-form-row/config-list-empty-state.component.jsx", () => ({
default: ({ actionLabel }) => <div>{actionLabel}</div>
}));
vi.mock("../form-list-move-arrows/form-list-move-arrows.component", () => ({
default: () => null
}));
vi.mock("../responsive-table/responsive-table.component", () => ({
default: () => null
}));
vi.mock("./shop-employees-add-vacation.component", () => ({
default: () => null
}));
vi.mock("../../utils/Ciecaselect", () => ({
default: () => []
}));
const bodyshop = {
id: "shop-1",
imexshopid: "split-shop-1",
md_responsibility_centers: {
costs: []
}
};
describe("ShopEmployeesFormComponent", () => {
let formInstance;
beforeEach(() => {
vi.clearAllMocks();
useQueryMock.mockImplementation((query) => {
if (query === QUERY_EMPLOYEE_BY_ID) {
return {
error: null,
data: null,
refetch: vi.fn(),
loading: false
};
}
return {
error: null,
data: null,
loading: false
};
});
useMutationMock.mockImplementation((mutation) => {
if (mutation === INSERT_EMPLOYEES) return [insertEmployeesMock];
if (mutation === UPDATE_EMPLOYEE) return [updateEmployeeMock];
if (mutation === DELETE_VACATION) return [deleteVacationMock];
return [vi.fn()];
});
apolloClientQueryMock.mockResolvedValue({
data: {
employees_aggregate: {
aggregate: {
count: 0
},
nodes: []
},
users: []
}
});
useApolloClient.mockReturnValue({
query: apolloClientQueryMock
});
insertEmployeesMock.mockResolvedValue({
data: {
insert_employees: {
returning: [
{
id: "employee-123",
first_name: "Jamie",
last_name: "Rivera",
employee_number: "42",
active: true,
termination_date: null,
hire_date: "2026-04-20",
flat_rate: false,
rates: [],
pin: "1234",
user_email: null
}
]
}
}
});
function TestHarness({ onFormReady }) {
const [form] = Form.useForm();
useEffect(() => {
onFormReady(form);
}, [form, onFormReady]);
return <ShopEmployeesFormComponent bodyshop={bodyshop} form={form} />;
}
render(
<TestHarness
onFormReady={(form) => {
formInstance = form;
}}
/>
);
});
it("marks a new employee form clean after save", async () => {
fireEvent.change(screen.getByRole("textbox", { name: "First Name" }), {
target: { value: "Jamie" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Last Name" }), {
target: { value: "Rivera" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Employee Number" }), {
target: { value: "42" }
});
fireEvent.change(screen.getByRole("textbox", { name: "PIN" }), {
target: { value: "1234" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Hire Date" }), {
target: { value: "2026-04-20" }
});
const saveButton = screen.getByRole("button", { name: "Save Employee" });
await waitFor(() => {
expect(saveButton.disabled).toBe(false);
});
fireEvent.click(saveButton);
await waitFor(() => {
expect(insertEmployeesMock).toHaveBeenCalledWith({
variables: {
employees: [
expect.objectContaining({
first_name: "Jamie",
last_name: "Rivera",
employee_number: "42",
pin: "1234",
hire_date: "2026-04-20",
shopid: "shop-1"
})
]
},
refetchQueries: ["QUERY_EMPLOYEES"]
});
});
await waitFor(() => {
expect(formInstance.isFieldsTouched()).toBe(false);
});
expect(notification.success).toHaveBeenCalledWith({
title: "Saved"
});
expect(navigateMock).toHaveBeenCalledWith({
search: "employeeId=employee-123"
});
});
it("saves a new employee and opens a fresh employee form when save and new is clicked", async () => {
fireEvent.change(screen.getByRole("textbox", { name: "First Name" }), {
target: { value: "Jamie" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Last Name" }), {
target: { value: "Rivera" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Employee Number" }), {
target: { value: "42" }
});
fireEvent.change(screen.getByRole("textbox", { name: "PIN" }), {
target: { value: "1234" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Hire Date" }), {
target: { value: "2026-04-20" }
});
fireEvent.click(screen.getByRole("button", { name: "Save and New" }));
await waitFor(() => {
expect(insertEmployeesMock).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(formInstance.isFieldsTouched()).toBe(false);
});
await waitFor(() => {
expect(screen.getByRole("textbox", { name: "First Name" })).toHaveValue("");
expect(screen.getByRole("textbox", { name: "Last Name" })).toHaveValue("");
expect(screen.getByRole("textbox", { name: "Employee Number" })).toHaveValue("");
expect(screen.getByRole("textbox", { name: "PIN" })).toHaveValue("");
expect(screen.getByRole("textbox", { name: "Hire Date" })).toHaveValue("");
});
expect(screen.getByText("New Employee")).toBeInTheDocument();
expect(navigateMock).toHaveBeenCalledWith({
search: "employeeId=new"
});
expect(notification.success).toHaveBeenCalledWith({
title: "Saved"
});
});
it("blocks saving when the user email belongs to another employee in the shop", async () => {
apolloClientQueryMock.mockImplementation(({ query }) => {
if (query === CHECK_EMPLOYEE_EMAIL) {
return Promise.resolve({
data: {
users: [{ email: "jamie@example.com" }],
employees_aggregate: {
aggregate: {
count: 1
},
nodes: [{ id: "other-employee" }]
}
}
});
}
return Promise.resolve({
data: {
employees_aggregate: {
aggregate: {
count: 0
},
nodes: []
},
users: []
}
});
});
fireEvent.change(screen.getByRole("textbox", { name: "First Name" }), {
target: { value: "Jamie" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Last Name" }), {
target: { value: "Rivera" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Employee Number" }), {
target: { value: "42" }
});
fireEvent.change(screen.getByRole("textbox", { name: "PIN" }), {
target: { value: "1234" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Hire Date" }), {
target: { value: "2026-04-20" }
});
fireEvent.change(screen.getByRole("textbox", { name: "User Email" }), {
target: { value: "jamie@example.com" }
});
fireEvent.click(screen.getByRole("button", { name: "Save Employee" }));
expect(await screen.findByText("User email already assigned")).toBeInTheDocument();
expect(insertEmployeesMock).not.toHaveBeenCalled();
expect(notification.success).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,4 @@
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Card, Tabs } from "antd"; import { Button, Card, Tabs } from "antd";
import queryString from "query-string"; import queryString from "query-string";
import { useRef } from "react"; import { useRef } from "react";

View File

@@ -12,6 +12,8 @@ import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.c
import { buildSectionActionButton, renderListOrEmpty } from "../layout-form-row/config-list-actions.utils.jsx"; import { buildSectionActionButton, renderListOrEmpty } from "../layout-form-row/config-list-actions.utils.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx"; import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils.js";
import { import {
INLINE_TITLE_GROUP_STYLE, INLINE_TITLE_GROUP_STYLE,
INLINE_TITLE_HANDLE_STYLE, INLINE_TITLE_HANDLE_STYLE,
@@ -25,16 +27,21 @@ import {
const timeZonesList = Intl.supportedValuesOf("timeZone"); const timeZonesList = Intl.supportedValuesOf("timeZone");
const mapStateToProps = createStructuredSelector({}); const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoGeneral); export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoGeneral);
export function ShopInfoGeneral({ form }) { export function ShopInfoGeneral({ form, bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
const insuranceCompanies = Form.useWatch(["md_ins_cos"], form) || []; const insuranceCompanies = Form.useWatch(["md_ins_cos"], form) || [];
const duplicateInsuranceCompanyIndexes = getDuplicateIndexSetByNormalizedName(insuranceCompanies, "name"); const duplicateInsuranceCompanyIndexes = getDuplicateIndexSetByNormalizedName(insuranceCompanies, "name");
const hasDMSKey = bodyshop ? bodyshopHasDmsKey(bodyshop) : false;
const dmsMode = bodyshop ? getDmsMode(bodyshop, "off") : "none";
const isReynoldsMode = hasDMSKey && dmsMode === DMS_MAP.reynolds;
return ( return (
<div> <div>
@@ -174,7 +181,9 @@ export function ShopInfoGeneral({ form }) {
> >
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} /> <div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
<div style={INLINE_TITLE_SWITCH_GROUP_STYLE}> <div style={INLINE_TITLE_SWITCH_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("bodyshop.fields.scoreboard_setup.ignore_blocked_days")}</div> <div style={INLINE_TITLE_LABEL_STYLE}>
{t("bodyshop.fields.scoreboard_setup.ignore_blocked_days")}
</div>
<Form.Item noStyle name={["scoreboard_target", "ignoreblockeddays"]} valuePropName="checked"> <Form.Item noStyle name={["scoreboard_target", "ignoreblockeddays"]} valuePropName="checked">
<Switch /> <Switch />
</Form.Item> </Form.Item>
@@ -311,7 +320,12 @@ export function ShopInfoGeneral({ form }) {
</Form.Item> </Form.Item>
</Col> </Col>
<Col xs={24} sm={12} xl={8}> <Col xs={24} sm={12} xl={8}>
<Form.Item key="use_fippa" label={t("bodyshop.fields.use_fippa")} name={["use_fippa"]} valuePropName="checked"> <Form.Item
key="use_fippa"
label={t("bodyshop.fields.use_fippa")}
name={["use_fippa"]}
valuePropName="checked"
>
<Switch /> <Switch />
</Form.Item> </Form.Item>
</Col> </Col>
@@ -478,7 +492,12 @@ export function ShopInfoGeneral({ form }) {
<div style={INLINE_TITLE_LABEL_STYLE}> <div style={INLINE_TITLE_LABEL_STYLE}>
{t("bodyshop.fields.system_settings.job_costing.use_paint_scale_data")} {t("bodyshop.fields.system_settings.job_costing.use_paint_scale_data")}
</div> </div>
<Form.Item noStyle key="use_paint_scale_data" name={["use_paint_scale_data"]} valuePropName="checked"> <Form.Item
noStyle
key="use_paint_scale_data"
name={["use_paint_scale_data"]}
valuePropName="checked"
>
<Switch /> <Switch />
</Form.Item> </Form.Item>
</div> </div>
@@ -558,7 +577,12 @@ export function ShopInfoGeneral({ form }) {
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow header={t("bodyshop.labels.shop_enabled_features")} id="sharing" grow style={{ marginBottom: 0 }}> <LayoutFormRow
header={t("bodyshop.labels.shop_enabled_features")}
id="sharing"
grow
style={{ marginBottom: 0 }}
>
<Form.Item <Form.Item
label={t("general.actions.sharetoteams")} label={t("general.actions.sharetoteams")}
valuePropName="checked" valuePropName="checked"
@@ -566,6 +590,16 @@ export function ShopInfoGeneral({ form }) {
> >
<Switch /> <Switch />
</Form.Item> </Form.Item>
{isReynoldsMode && (
<Form.Item
initialValue
label={t("bodyshop.fields.md_functionality_toggles.enhanced_early_ros")}
name={["md_functionality_toggles", "enhanced_early_ros"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
)}
</LayoutFormRow> </LayoutFormRow>
</div> </div>
</> </>
@@ -1582,7 +1616,6 @@ export function ShopInfoGeneral({ form }) {
form={form} form={form}
errorNames={[["md_parts_order_comment", field.name, "label"]]} errorNames={[["md_parts_order_comment", field.name, "label"]]}
noDivider noDivider
titleOnly
title={ title={
<div style={INLINE_TITLE_ROW_STYLE}> <div style={INLINE_TITLE_ROW_STYLE}>
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} /> <InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />

View File

@@ -4,7 +4,7 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component";
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";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop

View File

@@ -1,4 +1,4 @@
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Form, InputNumber } from "antd"; import { Form, InputNumber } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";

View File

@@ -1,5 +1,5 @@
import { DeleteFilled } from "@ant-design/icons"; import { DeleteFilled } from "@ant-design/icons";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Col, DatePicker, Divider, Form, Input, InputNumber, Radio, Row, Select, Space, Switch } from "antd"; import { Button, Col, DatePicker, Divider, Form, Input, InputNumber, Radio, Row, Select, Space, Switch } from "antd";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -157,36 +157,36 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
</Col> </Col>
{HasFeatureAccess({ featureName: "export", bodyshop }) && {HasFeatureAccess({ featureName: "export", bodyshop }) &&
ClosingPeriod.treatment === "on" && ( ClosingPeriod.treatment === "on" && (
<Col xs={24} sm={12} xl={8}> <Col xs={24} sm={12} xl={8}>
<Form.Item <Form.Item
key="ClosingPeriod" key="ClosingPeriod"
name={["accountingconfig", "ClosingPeriod"]} name={["accountingconfig", "ClosingPeriod"]}
label={t("bodyshop.fields.closingperiod")} label={t("bodyshop.fields.closingperiod")}
> >
<DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} /> <DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} />
</Form.Item> </Form.Item>
</Col> </Col>
)} )}
{HasFeatureAccess({ featureName: "export", bodyshop }) && {HasFeatureAccess({ featureName: "export", bodyshop }) &&
ADPPayroll.treatment === "on" && ( ADPPayroll.treatment === "on" && (
<Col xs={24} sm={12} xl={8}> <Col xs={24} sm={12} xl={8}>
<Form.Item <Form.Item
key="companyCode" key="companyCode"
name={["accountingconfig", "companyCode"]} name={["accountingconfig", "companyCode"]}
label={t("bodyshop.fields.companycode")} label={t("bodyshop.fields.companycode")}
> >
<Input /> <Input />
</Form.Item> </Form.Item>
</Col> </Col>
)} )}
{HasFeatureAccess({ featureName: "export", bodyshop }) && {HasFeatureAccess({ featureName: "export", bodyshop }) &&
ADPPayroll.treatment === "on" && ( ADPPayroll.treatment === "on" && (
<Col xs={24} sm={12} xl={8}> <Col xs={24} sm={12} xl={8}>
<Form.Item key="batchID" name={["accountingconfig", "batchID"]} label={t("bodyshop.fields.batchid")}> <Form.Item key="batchID" name={["accountingconfig", "batchID"]} label={t("bodyshop.fields.batchid")}>
<Input /> <Input />
</Form.Item> </Form.Item>
</Col> </Col>
)} )}
{HasFeatureAccess({ featureName: "export", bodyshop }) && !hasDMSKey && ( {HasFeatureAccess({ featureName: "export", bodyshop }) && !hasDMSKey && (
<> <>
<Col xs={24} sm={12} xl={8}> <Col xs={24} sm={12} xl={8}>
@@ -512,6 +512,15 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
> >
<InputNumber min={0} max={100} suffix="%" /> <InputNumber min={0} max={100} suffix="%" />
</Form.Item> </Form.Item>
{bodyshop.cdk_dealerid && (
<Form.Item
label={t("bodyshop.fields.dms.disablecontact")}
valuePropName="checked"
name={["cdk_configuration", "disablecontact"]}
>
<Switch />
</Form.Item>
)}
{bodyshop.pbs_serialnumber && ( {bodyshop.pbs_serialnumber && (
<Form.Item <Form.Item
label={t("bodyshop.fields.dms.disablecontactvehiclecreation")} label={t("bodyshop.fields.dms.disablecontactvehiclecreation")}
@@ -810,16 +819,6 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
> >
<Input onBlur={handleBlur} /> <Input onBlur={handleBlur} />
</Form.Item> </Form.Item>
{!hasDMSKey && (
<Form.Item
label={t("bodyshop.fields.responsibilitycenter_accountitem")}
key={`${index}accountitem`}
name={[field.name, "accountitem"]}
rules={[{ required: true }]}
>
<Input onBlur={handleBlur} />
</Form.Item>
)}
{hasDMSKey && !bodyshop.rr_dealerid && ( {hasDMSKey && !bodyshop.rr_dealerid && (
<> <>
<Form.Item <Form.Item

View File

@@ -16,7 +16,7 @@ import { DEFAULT_TRANSLUCENT_CARD_COLOR, getTintedCardSurfaceStyles } from "./sh
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";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop

View File

@@ -7,7 +7,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import JobSearchSelect from "../job-search-select/job-search-select.component"; import JobSearchSelect from "../job-search-select/job-search-select.component";
import JobsDetailLaborContainer from "../jobs-detail-labor/jobs-detail-labor.container"; import JobsDetailLaborContainer from "../jobs-detail-labor/jobs-detail-labor.container";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,

View File

@@ -12,7 +12,7 @@ import { selectTechnician } from "../../redux/tech/tech.selectors";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import TechJobPrintTickets from "../tech-job-print-tickets/tech-job-print-tickets.component"; import TechJobPrintTickets from "../tech-job-print-tickets/tech-job-print-tickets.component";
import TechClockInComponent from "./tech-job-clock-in-form.component"; import TechClockInComponent from "./tech-job-clock-in-form.component";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({

View File

@@ -14,7 +14,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import { CalculateAllocationsTotals } from "../labor-allocations-table/labor-allocations-table.utility"; import { CalculateAllocationsTotals } from "../labor-allocations-table/labor-allocations-table.utility";
import TechJobClockoutDelete from "../tech-job-clock-out-delete/tech-job-clock-out-delete.component"; import TechJobClockoutDelete from "../tech-job-clock-out-delete/tech-job-clock-out-delete.component";
import { LaborAllocationContainer } from "../time-ticket-modal/time-ticket-modal.component"; import { LaborAllocationContainer } from "../time-ticket-modal/time-ticket-modal.component";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js"; import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";

View File

@@ -11,7 +11,7 @@ import { createStructuredSelector } from "reselect";
import { techLogout } from "../../redux/tech/tech.actions"; import { techLogout } from "../../redux/tech/tech.actions";
import { selectTechnician } from "../../redux/tech/tech.selectors"; import { selectTechnician } from "../../redux/tech/tech.selectors";
import { BsKanban } from "react-icons/bs"; import { BsKanban } from "react-icons/bs";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import { setModalContext } from "../../redux/modals/modals.actions"; import { setModalContext } from "../../redux/modals/modals.actions";

View File

@@ -1,5 +1,5 @@
import { EditFilled, SyncOutlined } from "@ant-design/icons"; import { EditFilled, SyncOutlined } from "@ant-design/icons";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Card, Checkbox, Space } from "antd"; import { Button, Card, Checkbox, Space } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component"; import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";

View File

@@ -1,5 +1,5 @@
import { useLazyQuery } from "@apollo/client/react"; import { useLazyQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Card, Form, Input, InputNumber, Select, Space, Switch } from "antd"; import { Card, Form, Input, InputNumber, Select, Space, Switch } from "antd";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";

View File

@@ -1,8 +1,8 @@
import { PageHeader } from "@ant-design/pro-layout"; import { PageHeader } from "@ant-design/pro-layout";
import { useMutation, useQuery } from "@apollo/client/react"; import { useMutation, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Form, Modal, Space } from "antd"; import { Button, Form, Modal, Space } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState, useRef } 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,21 +15,27 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time-tickets-commit-toggle.component"; import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time-tickets-commit-toggle.component";
import TimeTicketModalComponent from "./time-ticket-modal.component"; import TimeTicketModalComponent from "./time-ticket-modal.component";
import { insertAuditTrail } from "../../redux/application/application.actions.js";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { buildTimeTicketAuditSummary } from "../../utils/auditTrailDetails.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
timeTicketModal: selectTimeTicket, timeTicketModal: selectTimeTicket,
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("timeTicket")) toggleModalVisible: () => dispatch(toggleModalVisible("timeTicket")),
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
}); });
export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, bodyshop }) { export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, bodyshop, insertAuditTrail }) {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const [enterAgain, setEnterAgain] = useState(false); const [enterAgain, setEnterAgain] = useState(false);
const lastSubmittedRef = useRef(null);
const [lineTicketRefreshKey, setLineTicketRefreshKey] = useState(0); const [lineTicketRefreshKey, setLineTicketRefreshKey] = useState(0);
const [insertTicket] = useMutation(INSERT_NEW_TIME_TICKET); const [insertTicket] = useMutation(INSERT_NEW_TIME_TICKET);
@@ -48,47 +54,77 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only" nextFetchPolicy: "network-only"
}); });
const employees = EmployeeAutoCompleteData?.employees ?? [];
const handleFinish = (values) => { const handleFinish = (values) => {
lastSubmittedRef.current = values;
setLoading(true); setLoading(true);
const emps = EmployeeAutoCompleteData?.employees.filter((e) => e.id === values.employeeid); const isEdit = Boolean(timeTicketModal.context.id);
if (timeTicketModal.context.id) { const emps = employees.filter((employee) => employee.id === values.employeeid);
updateTicket({ const mutation = isEdit
variables: { ? updateTicket({
timeticketId: timeTicketModal.context.id, variables: {
timeticket: { timeticketId: timeTicketModal.context.id,
...values, timeticket: {
rate: emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate : null
}
}
})
.then(handleMutationSuccess)
.catch(handleMutationError);
} else {
//Get selected employee rate.
insertTicket({
variables: {
timeTicketInput: [
{
...values, ...values,
rate: rate:
emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0].rate : null, emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate : null
bodyshopid: bodyshop.id,
created_by: timeTicketModal.context.created_by
} }
] }
} })
}) : insertTicket({
.then(handleMutationSuccess) variables: {
.catch(handleMutationError); timeTicketInput: [
} {
...values,
rate:
emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0].rate : null,
bodyshopid: bodyshop.id,
created_by: timeTicketModal.context.created_by
}
]
}
});
mutation.then((result) => handleMutationSuccess(result, isEdit)).catch(handleMutationError);
}; };
const handleMutationSuccess = () => { const handleMutationSuccess = (result, isEdit) => {
notification.success({ notification.success({
title: t("timetickets.successes.created") title: t("timetickets.successes.created")
}); });
const savedTicket =
result?.data?.update_timetickets?.returning?.[0] ?? result?.data?.insert_timetickets?.returning?.[0] ?? {};
const originalTicket = timeTicketModal.context?.timeticket ?? {};
const submittedValues = {
...(lastSubmittedRef.current ?? {}),
date: lastSubmittedRef.current?.date ?? savedTicket.date ?? originalTicket.date ?? null,
employeeid: lastSubmittedRef.current?.employeeid ?? savedTicket.employeeid ?? originalTicket.employeeid ?? null,
jobid:
lastSubmittedRef.current?.jobid ??
savedTicket.jobid ??
timeTicketModal.context.jobId ??
originalTicket.job?.id ??
originalTicket.jobid ??
null
};
const auditSummary = buildTimeTicketAuditSummary({
originalTicket,
submittedValues,
employees
});
if (auditSummary.jobid) {
insertAuditTrail({
jobid: auditSummary.jobid,
operation: isEdit
? AuditTrailMapping.timeticketupdated(auditSummary.employeeName, auditSummary.date, auditSummary.details)
: AuditTrailMapping.timeticketcreated(auditSummary.employeeName, auditSummary.date, auditSummary.details),
type: isEdit ? "timeticketupdated" : "timeticketcreated"
});
}
// Refresh parent screens (Job Labor tab, etc.) // Refresh parent screens (Job Labor tab, etc.)
if (timeTicketModal.actions.refetch) timeTicketModal.actions.refetch(); if (timeTicketModal.actions.refetch) timeTicketModal.actions.refetch();

View File

@@ -11,12 +11,13 @@ import ResponsiveTable from "../responsive-table/responsive-table.component";
export default function VehiclesListComponent({ loading, vehicles, total, refetch, basePath = "/manage" }) { export default function VehiclesListComponent({ loading, vehicles, total, refetch, basePath = "/manage" }) {
const search = queryString.parse(useLocation().search); const search = queryString.parse(useLocation().search);
const { const { page, pageSize } = search;
page
//sortcolumn, sortorder,
} = search;
const history = useNavigate(); const history = useNavigate();
const currentPage = Number.parseInt(page || "1", 10);
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
const [state, setState] = useState({ const [state, setState] = useState({
sortedInfo: {}, sortedInfo: {},
filteredInfo: { text: "" } filteredInfo: { text: "" }
@@ -43,9 +44,7 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc
key: "description", key: "description",
render: (text, record) => { render: (text, record) => {
return ( return (
<span>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${ <span>{`${record.v_model_yr || ""} ${record.v_color || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""} `}</span>
record.v_model_desc || ""
} ${record.v_color || ""}`}</span>
); );
} }
}, },
@@ -62,10 +61,14 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc
]; ];
const handleTableChange = (pagination, filters, sorter) => { const handleTableChange = (pagination, filters, sorter) => {
const nextPageSize = pagination?.pageSize || currentPageSize;
const pageSizeChanged = nextPageSize !== currentPageSize;
setState({ ...state, filteredInfo: filters, sortedInfo: sorter }); setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
const updatedSearch = { const updatedSearch = {
...search, ...search,
page: pagination.current, pageSize: nextPageSize,
page: pageSizeChanged ? 1 : pagination.current,
sortcolumn: sorter.columnKey, sortcolumn: sorter.columnKey,
sortorder: sorter.order sortorder: sorter.order
}; };
@@ -106,7 +109,13 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc
> >
<ResponsiveTable <ResponsiveTable
loading={loading} loading={loading}
pagination={{ placement: "top", pageSize: pageLimit, current: parseInt(page || 1), total: total }} pagination={{
placement: "top",
pageSize: currentPageSize,
current: currentPage,
showSizeChanger: true,
total: total
}}
columns={columns} columns={columns}
mobileColumnKeys={["v_vin", "description", "plate_no"]} mobileColumnKeys={["v_vin", "description", "plate_no"]}
rowKey="id" rowKey="id"

View File

@@ -16,14 +16,18 @@ const mapStateToProps = createStructuredSelector({
export function VehiclesListContainer({ isPartsEntry }) { export function VehiclesListContainer({ isPartsEntry }) {
const searchParams = queryString.parse(useLocation().search); const searchParams = queryString.parse(useLocation().search);
const { page, sortcolumn, sortorder, search } = searchParams; const { page, sortcolumn, sortorder, search, pageSize } = searchParams;
const basePath = getPartsBasePath(isPartsEntry); const basePath = getPartsBasePath(isPartsEntry);
const currentPage = Number.parseInt(page || "1", 10);
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
const { loading, error, data, refetch } = useQuery(QUERY_ALL_VEHICLES_PAGINATED, { const { loading, error, data, refetch } = useQuery(QUERY_ALL_VEHICLES_PAGINATED, {
variables: { variables: {
search: search || "", search: search || "",
offset: page ? (page - 1) * pageLimit : 0, offset: (currentPage - 1) * currentPageSize,
limit: pageLimit, limit: currentPageSize,
order: [ order: [
{ {
[sortcolumn || "created_at"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc" [sortcolumn || "created_at"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc"

View File

@@ -1,6 +1,6 @@
import { DeleteFilled } from "@ant-design/icons"; import { DeleteFilled } from "@ant-design/icons";
import { useApolloClient } from "@apollo/client/react"; import { useApolloClient } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from "antd"; import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from "antd";
import { PageHeader } from "@ant-design/pro-layout"; import { PageHeader } from "@ant-design/pro-layout";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";

View File

@@ -14,7 +14,7 @@ import {
} from "../../graphql/notifications.queries.js"; } from "../../graphql/notifications.queries.js";
import { useMutation } from "@apollo/client/react"; import { useMutation } from "@apollo/client/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { FEATURE_FLAGS_CHANGED_EVENT, useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { INITIAL_NOTIFICATIONS, SocketContext } from "./useSocket.js"; import { INITIAL_NOTIFICATIONS, SocketContext } from "./useSocket.js";
const LIMIT = INITIAL_NOTIFICATIONS; const LIMIT = INITIAL_NOTIFICATIONS;
@@ -280,6 +280,10 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
} }
}; };
const handleFeatureFlagsChanged = (payload) => {
window.dispatchEvent(new CustomEvent(FEATURE_FLAGS_CHANGED_EVENT, { detail: payload }));
};
const syncCurrentTokenToSocket = async () => { const syncCurrentTokenToSocket = async () => {
try { try {
if (!auth.currentUser || !bodyshop?.id) return; if (!auth.currentUser || !bodyshop?.id) return;
@@ -574,6 +578,7 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
socketInstance.on("notification", handleNotification); socketInstance.on("notification", handleNotification);
socketInstance.on("sync-notification-read", handleSyncNotificationRead); socketInstance.on("sync-notification-read", handleSyncNotificationRead);
socketInstance.on("sync-all-notifications-read", handleSyncAllNotificationsRead); socketInstance.on("sync-all-notifications-read", handleSyncAllNotificationsRead);
socketInstance.on(FEATURE_FLAGS_CHANGED_EVENT, handleFeatureFlagsChanged);
socketInstance.on("token-updated", handleTokenUpdated); socketInstance.on("token-updated", handleTokenUpdated);
if (tokenSyncIntervalRef.current) { if (tokenSyncIntervalRef.current) {

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