Compare commits

..

361 Commits

Author SHA1 Message Date
Patrick Fic
4043bd3d33 Merged in feature/IO-3722-disable-contact-fortellis (pull request #3286)
IO-3722 Remove delivery date for bypass vehicles.

Approved-by: Dave Richer
2026-05-28 18:33:50 +00:00
Patrick Fic
c1c0b35c8f IO-3722 Remove delivery date for bypass vehicles. 2026-05-28 11:32:23 -07:00
Patrick Fic
4fd2f034a3 Merged in feature/IO-3722-disable-contact-fortellis (pull request #3283)
IO-3722 Remove customer lookup by Vehicle Owner.

Approved-by: Dave Richer
2026-05-28 16:55:21 +00:00
Patrick Fic
aa3b303fe9 IO-3722 Remove customer lookup by Vehicle Owner. 2026-05-28 09:53:40 -07:00
Patrick Fic
bd25245290 Merged in feature/IO-3722-disable-contact-fortellis (pull request #3281)
IO-3722 Fix undefined customer ref.
2026-05-27 21:19:02 +00:00
Patrick Fic
468ed23f73 IO-3722 Fix undefined customer ref. 2026-05-27 14:18:31 -07:00
Patrick Fic
6472b053ed Merged in feature/IO-3722-disable-contact-fortellis (pull request #3280)
Resolve inversed if statement.
2026-05-27 19:54:30 +00:00
Patrick Fic
322ebd3bc7 Resolve inversed if statement. 2026-05-27 12:46:09 -07:00
Patrick Fic
169070594c Merged in feature/IO-3722-disable-contact-fortellis (pull request #3279)
IO-3722 Add additional await.
2026-05-27 19:42:38 +00:00
Patrick Fic
0f800c5a4c IO-3722 Add additional await. 2026-05-27 12:40:41 -07:00
Dave Richer
0974e69a50 Merged in feature/IO-3722-disable-contact-fortellis (pull request #3277)
IO-3722 Disable contact API calls for Fortellis.
2026-05-27 18:36:51 +00:00
Patrick FIc
345a470731 IO-3722 Disable contact API calls for Fortellis. 2026-05-27 10:31:33 -07: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
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
ab606a4266 release/2026-05-22 - Remove uncessary require 2026-05-20 14:46:52 -04: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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
d8924d6cf3 Merged in release/2026-04-03 (pull request #3177)
IO-3637 DMS ID Production Board Column
2026-04-03 01:37:20 +00:00
Allan Carr
a1d0e2df93 Merged in feature/IO-3637-DMS-ID-Production-Board-Column (pull request #3175)
IO-3637 DMS ID Production Board Column

Approved-by: Dave Richer
Approved-by: Patrick Fic
2026-04-03 01:32:25 +00:00
Allan Carr
9a86a337bb IO-3637 DMS ID Production Board Column
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-02 15:50:56 -07: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
a287601f27 Merge branch 'release/2026-04-03' into feature/IO-3624-Shop-Config-UX-Refresh 2026-04-01 14:40:16 -04:00
Dave
7688f22161 release/2026-04-03 - Clean up localstack endpoints / env check 2026-04-01 14:39:34 -04:00
Dave Richer
2cc6774334 Merged in release/2026-04-03 (pull request #3172)
IO-1366 Bill Reexport Audit Log
2026-03-31 20:19:28 +00:00
Allan Carr
efdcd06921 Merged in feature/IO-1366-Re-export-Bill-Audit-Log (pull request #3170)
IO-1366 Bill Reexport Audit Log

Approved-by: Dave Richer
2026-03-31 20:18:44 +00:00
Dave
d2dd276ce7 feature/.feature/IO-3624-Shop-Config-UX-Refresh - Bump Deps 2026-03-31 16:16:14 -04:00
Allan Carr
c0a37d7c1a IO-1366 Bill Reexport Audit Log
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-31 09:50:08 -07:00
Dave
6947ad54a7 Merge branch 'release/2026-04-03' into feature/IO-3624-Shop-Config-UX-Refresh 2026-03-30 17:25:18 -04:00
Dave
6759bc5865 release/2026-04-03 - Remove console.dir 2026-03-30 17:24:42 -04:00
Dave Richer
db52bf0e94 Merged in release/2026-04-03 (pull request #3169)
Release/2026 04 03
2026-03-30 19:09:17 +00:00
Patrick Fic
04732fc6cd Merged in feature/IO-3356-rc-ford-ro-updates (pull request #3166)
IO-3356 Add CSR and Shop values per Grant @ RC Ford.

Approved-by: Dave Richer
2026-03-30 19:08:16 +00:00
Dave
5d95275c0b feature/IO-3624-Shop-Config-UX-Refresh - bump deps 2026-03-30 15:07:57 -04:00
Patrick Fic
a65a34ef1f Merged in feature/IO-3515-maintain-discount-on-ai-vendor (pull request #3167)
IO-3515 Retain discount application when AI vendor added.

Approved-by: Dave Richer
2026-03-30 19:07:43 +00:00
Patrick Fic
1ea7798eeb IO-3515 Retain discount application when AI vendor added. 2026-03-30 11:59:08 -07:00
Patrick Fic
7739d48741 IO-3356 Add CSR and Shop values per Grant @ RC Ford. 2026-03-30 11:44:38 -07: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
ed0693fc5b Merged in release/2026-04-03 (pull request #3165)
IO-3629 PostBatchWip Rtn != 0 error
2026-03-30 15:08:05 +00:00
Allan Carr
074be66b8c Merged in feature/IO-3629-PostBatchWip-rtn-1-Catch (pull request #3163)
IO-3629 PostBatchWip Rtn != 0 error

Approved-by: Dave Richer
2026-03-30 15:07:15 +00:00
Allan Carr
8db8744782 IO-3629 PostBatchWip Rtn != 0 error
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-27 15:38:27 -07:00
Dave Richer
c2d8d78e0a Merged in hotfix/2026-03-27 (pull request #3162)
Hotfix/2026 03 27
2026-03-27 19:16:16 +00:00
Dave Richer
e9e189d032 Merged in release/2026-04-03 (pull request #3161)
Release/2026 04 03
2026-03-27 18:49:31 +00:00
Dave Richer
71aec6d0c5 Merged in hotfix/2026-03-27 (pull request #3159)
Hotfix/2026 03 27
2026-03-27 18:48:24 +00:00
Dave
f89d7865fa Restore hasura metadata tables from master-AIO 2026-03-27 14:46:00 -04:00
Dave
8fd368ebb4 Revert hasura metadata tables changes 2026-03-27 14:45:01 -04:00
Dave
132fc0a20f hotfix/2026-03-27 - Missing chatter stuff. 2026-03-27 14:36:37 -04:00
Dave
0b470e3c31 Bump deps 2026-03-27 13:46:28 -04: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
ab8b44bee4 Merged in release/2026-04-03 (pull request #3158)
Release/2026 04 03
2026-03-25 22:37:21 +00:00
Allan Carr
9ea2d83043 Merged in feature/IO-3599-Taxable-Amount (pull request #3155)
IO-3599 Taxable Amount

Approved-by: Dave Richer
2026-03-25 22:36:08 +00:00
Allan Carr
abad7d5f00 Merged in feature/IO-3627-Courtesy-Car-Create-RO (pull request #3156)
IO-3627 Courtesy Car Create RO

Approved-by: Dave Richer
2026-03-25 22:35:33 +00:00
Patrick Fic
d4c7298334 Eisgnature Migrations, webhook handling, and clean up. 2026-03-25 15:24:14 -07:00
Dave
d497ec9f7d feature/IO-3624-Shop-Config-UX-Refresh -Final Push! 2026-03-25 15:58:51 -04:00
Dave
e49500887d IO-3624 Finalize admin config UX and validation polish 2026-03-25 15:25:59 -04:00
Dave
b8246e03c1 IO-3624 Polish config empty states and admin cards 2026-03-25 10:16:48 -04:00
Allan Carr
cc623b7cbb IO-3627 Courtesy Car Create RO
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-24 20:12:26 -07:00
Dave
3aa19ec09f IO-3624 Add drag reorder to job statuses 2026-03-24 21:21:03 -04:00
Allan Carr
c97213bc96 IO-3599 Taxable Amount
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-24 18:12:04 -07:00
Dave
866e9581c2 IO-3624 Extract shared title-row UI and polish config forms 2026-03-24 20:56:30 -04:00
Dave
1102670e66 feature/IO-3624-Shop-Config-UX-Refresh - DMS Sections 2026-03-24 17:45:42 -04:00
Dave
591439b79c feature/IO-3624-Shop-Config-UX-Refresh - Add Quick Select Jump 2026-03-24 17:06:09 -04:00
Dave
2de605e520 IO-3624 Refine Rome responsibility center tax layout 2026-03-24 16:37:03 -04:00
Dave
2690e09626 Merge remote-tracking branch 'origin/release/2026-04-03' into feature/IO-3624-Shop-Config-UX-Refresh
# Conflicts:
#	client/src/components/shop-employees/shop-employees-form.component.jsx
2026-03-24 13:55:37 -04:00
Dave
dd306e1a7b feature/IO-3624-Shop-Config-UX-Refresh - Add missing es/fr keys to translations 2026-03-24 13:50:57 -04:00
Allan Carr
9b1488ac3b Merged in feature/IO-3609-Bill-Cost-Calculation-Toggle (pull request #3151)
IO-3609 Bill Cost Calculation Toggle

Approved-by: Dave Richer
2026-03-24 17:50:06 +00:00
Allan Carr
7bab9bf4cb Merged in feature/IO-3623-Extend-Vendor-Discount (pull request #3152)
IO-3623 Extend Vendor Discount to Precision 3

Approved-by: Dave Richer
2026-03-24 17:49:40 +00:00
Allan Carr
8278242e6f Merged in feature/IO-3622-Employee-Delete-Rate (pull request #3153)
IO-3622 Employee Delete Rate

Approved-by: Dave Richer
2026-03-24 17:49:29 +00:00
Dave
fd712da4a3 IO-3624 Polish employee and team config layouts 2026-03-24 12:50:11 -04:00
Dave
bcb693f03c feature/IO-3624-Shop-Config-UX-Refresh - Add missing es/fr keys to translations 2026-03-24 11:55:33 -04:00
Dave
c33a3118bc IO-3624 Polish remaining shop config section cards 2026-03-24 11:51:55 -04:00
Dave
d23a182650 IO-3624 Refresh shop config list rows and color UX 2026-03-24 10:54:42 -04:00
Allan Carr
aa81cddcf1 IO-3622 Employee Delete Rate
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-23 16:46:28 -07:00
Allan Carr
85e60dcd6b IO-3623 Extend Vendor Discount to Precision 3
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-23 16:00:12 -07:00
Allan Carr
a005f1bb45 IO-3609 Bill Cost Calculation Toggle
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-23 15:52:49 -07: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
f904fa4e18 Merge branch 'feature/IO-3587-Commision-Cut-clean' into release/2026-04-03 2026-03-23 13:04:12 -04:00
Dave
b5997d0b8f feature/IO-3587-Commision-Cut-Clean - Cleanup 2026-03-23 13:00:51 -04:00
Dave
19f918b695 feature/IO-3587-Commision-Cut-Clean - Package Bumps 2026-03-20 15:23:29 -04:00
Dave
b5b56f12aa Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3587-Commision-Cut-clean 2026-03-20 15:05:23 -04:00
Dave Richer
76b15f0521 Merged in hotfix/2026-03-20 (pull request #3149)
Hotfix/2026 03 20
2026-03-20 19:04:11 +00:00
Dave Richer
27ffee0c7a Merged in hotfix/2026-03-20 (pull request #3147)
Fix RR
2026-03-20 18:56:08 +00:00
Dave
03e06cfd96 Merge remote-tracking branch 'origin/feature/IO-3515-bill-ocr-feedback' into hotfix/2026-03-20 2026-03-20 14:55:33 -04:00
Dave
0622696650 Fix RR 2026-03-20 14:55:08 -04:00
Patrick Fic
187938286d Merged in feature/IO-3515-bill-ocr-feedback (pull request #3145)
IO-3515 Add shopname to bill ai feedback.
2026-03-20 18:15:23 +00:00
Patrick Fic
51fca7a63c IO-3515 Add shopname to bill ai feedback. 2026-03-20 09:13:01 -07:00
Patrick Fic
6ad0272135 Merged in feature/IO-3515-bill-ocr-feedback (pull request #3142)
Feature/IO-3515 bill ocr feedback

Approved-by: Dave Richer
2026-03-19 22:44:36 +00:00
Dave Richer
86db96f47d Merged in feature/IO-3587-Commision-Cut-clean (pull request #3143)
Feature/IO-3587 Commision Cut clean
2026-03-19 22:44:22 +00:00
Dave
9d9e626cfe feature/IO-3587-Commision-Cut-Clean - Split localStack client into smaller files / seperated by concerns 2026-03-19 18:41:43 -04:00
Dave
285043a6ba feature/IO-3587-Commision-Cut - Improved localstack client 2026-03-19 18:23:58 -04:00
Patrick Fic
5812d53efc IO-3515 Improved feedback layout. 2026-03-19 14:54:40 -07:00
Patrick Fic
b2231007b6 IO-3515 Bill OCR Feedback. 2026-03-19 14:47:22 -07:00
Dave
fcc05156bc feature/IO-3587-Commision-Cut - Improved localstack client 2026-03-19 17:39:52 -04:00
Dave
31c6e7c0e5 feature/IO-3587-Commision-Cut - Improved localstack client 2026-03-19 17:14:32 -04:00
Dave
f0f5c09fd7 feature/IO-3587-Commision-Cut - Improved localstack client 2026-03-19 17:03:17 -04:00
Dave
debc67cc49 feature/IO-3587-Commision-Cut - Improved localstack client 2026-03-19 16:46:25 -04:00
Dave
281fe02820 feature/IO-3587-Commision-Cut - Improved localstack client 2026-03-19 16:18:22 -04:00
Dave
34c9a3854c feature/IO-3587-Commision-Cut - Improved local email / Test Plans 2026-03-19 15:51:55 -04:00
Allan Carr
ac8c84543a Merged in feature/IO-3616-Production-Phone#-Deeplink (pull request #3139)
IO-3616 Production Phone# Deeplinking

Approved-by: Dave Richer
2026-03-19 18:46:15 +00:00
Dave Richer
78a2ff0fa1 Merged in feature/IO-3587-Commision-Cut-clean (pull request #3140)
Feature/IO-3587 Commision Cut clean
2026-03-19 18:46:02 +00:00
Dave
98781a76e6 Localstack Email Viewer 2026-03-19 14:39:30 -04:00
Dave
172bbecff7 feature/IO-3587-Commision-Cut - Improved local email 2026-03-19 14:28:15 -04:00
Dave
3f03157834 feature/IO-3587-Commision-Cut - Additional Testing / Test Harness improvements 2026-03-18 16:50:57 -04:00
Dave Richer
dac1ed42df Merged in feature/IO-3587-Commision-Cut-clean (pull request #3138)
Feature/IO-3587 Commision Cut clean
2026-03-17 15:20:59 +00:00
Dave
782fa8a1c7 feature/IO-3587-Commision-Cut - restore test fixes 2026-03-17 11:09:26 -04:00
Dave
45688c0dde feature/IO-3587-Commision-Cut - Additional test, layout enhancements 2026-03-17 11:03:14 -04:00
Dave
aebd8da4ae feature/IO-3587-Commision-Cut - restore Hasura metadata and migrations 2026-03-17 11:01:39 -04:00
Dave
b4e8a80735 Package bump 2026-03-17 10:56:58 -04:00
Dave
73ba95a240 feature/IO-3587-Commision-Cut - rebuild from master-AIO 2026-03-17 10:50:22 -04:00
Allan Carr
bc5f9f88d1 IO-3616 Production Phone# Deeplinking
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-17 00:30:07 -07: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
Dave Richer
8af8c8039c Merged in release/2026-03-13 (pull request #3131)
Release/2026-03-13 into master-AIO - IO-3571, IO-3582, IO-3584, IO-3590, IO-3592, IO-3596, IO-3600, IO-3601, IO-3603, IO-3604, IO-3605, IO-3606, IO-3607, IO-3610
2026-03-14 00:58:46 +00:00
Allan Carr
6ef56f97c0 IO-2433 Missing Translation
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-12 16:58:59 -07:00
Dave
3a1d10b0d1 Merge remote-tracking branch 'origin/feature/IO-3610-Export-Log-DMS-Bug' into release/2026-03-13 2026-03-12 19:49:56 -04:00
Allan Carr
e6071709be IO-3610 Export Log DMS Bug
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-12 16:28:14 -07:00
Allan Carr
c95c11fd0e Merged in hotfix/2026-03-12 (pull request #3125)
IO-3585 saleClassValue fix
2026-03-12 19:01:16 +00:00
Allan Carr
1351fbb814 Merged in feature/IO-3585-Fortellis-Insert-and-Update-Vehicle (pull request #3123)
IO-3585 saleClassValue fix
2026-03-12 18:58:35 +00:00
Allan Carr
dcd3a078ef IO-3585 saleClassValue fix
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-12 11:58:40 -07:00
Allan Carr
bb8e140f6e Merged in feature/IO-3585-Fortellis-Insert-and-Update-Vehicle (pull request #3122)
IO-3585 saleClassValue fix
2026-03-12 18:57:07 +00:00
Dave Richer
bf11e10676 Merged in hotfix/2026-03-12 (pull request #3119)
hotfix/2026-03-12 - Be more specific on CDK error passing, resolve circular dependency
2026-03-12 16:38:15 +00:00
Dave Richer
92e6bdf2a2 Merged in hotfix/2026-03-12 (pull request #3117)
hotfix/2026-03-12 - Be more specific on CDK error passing, resolve circular dependency
2026-03-12 16:37:23 +00:00
Allan Carr
a02e336d73 Merged in feature/IO-3584-Duplicate-Job-with-Full-Rates (pull request #3116)
IO-3584 Duplicate Job with Full Rates

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Approved-by: Dave Richer
2026-03-03 18:03:41 +00:00
Dave
3b27120d77 hotfix/2026-03-03-RR-logging-Posting-Enhancements - Implement 2026-03-03 13:03:02 -05:00
Dave Richer
f350163056 Merged in feature/IO-3554-Form-Row-Layout (pull request #3068)
feature/IO-3554-Form-Row-Layout - dial in tables
2026-03-02 17:00:21 +00:00
Dave
db4d286a86 feature/IO-3554-Form-Row-Layout - dial in tables 2026-03-02 11:59:32 -05:00
Dave Richer
57cfecb7b8 Merged in feature/IO-3554-Form-Row-Layout (pull request #3066)
feature/IO-3554-Form-Row-Layout - Modify how truncation works on responsive tables.
2026-03-02 16:29:45 +00:00
Dave
56c24e3450 feature/IO-3554-Form-Row-Layout - Modify how truncation works on responsive tables. 2026-03-02 11:29:06 -05:00
Allan Carr
9a41cfd6af Merged in feature/IO-3585-Fortellis-Insert-and-Update-Vehicle (pull request #3064)
IO-3585 Fortellis Insert and Update Vehicle Info

Approved-by: Dave Richer
2026-03-02 15:44:29 +00:00
Dave
2934da4be9 Merge branch 'feature/IO-3586-Socket-Reconnect-Issues' into release/2026-02-27 2026-03-02 10:43:42 -05:00
Allan Carr
c2fb010a59 IO-3585 Fortellis Insert and Update Vehicle Info
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-01 22:03:23 -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
Dave Richer
ccba7b0137 Merged in hotfix/2026-06-27 (pull request #3061)
hotfix/2026-02-27 - Reynolds Estimate amounts on second call, + RR Docs in _ref
2026-02-27 21:15:52 +00:00
Patrick Fic
e25174ff97 IO-2433 Basic embedded authoring. 2026-02-27 13:15:10 -08:00
Dave Richer
c116007042 Merged in feature/IO-3554-Form-Row-Layout (pull request #3059)
feature/feature/IO-3554-Form-Row-Layout - Fix Monthly Job Costing (Dashboard)
2026-02-27 18:09:12 +00:00
Dave
31c7abab39 feature/feature/IO-3554-Form-Row-Layout - Fix Monthly Job Costing (Dashboard) 2026-02-27 13:08:34 -05:00
Dave Richer
589e537c94 Merged in feature/IO-3554-Form-Row-Layout (pull request #3057)
feature/feature/IO-3554-Form-Row-Layout - Revert Monthly Job Costing Table
2026-02-27 15:54:54 +00:00
Dave
b2f471fe9a feature/feature/IO-3554-Form-Row-Layout - Revert Monthly Job Costing Table 2026-02-27 10:53:57 -05:00
Dave Richer
7ea4f96664 Merged in feature/IO-3554-Form-Row-Layout (pull request #3055)
feature/feature/IO-3554-Form-Row-Layout - Responsive overhaul
2026-02-26 21:01:30 +00:00
Dave
fd6f46e39d feature/feature/IO-3554-Form-Row-Layout - Responsive overhaul 2026-02-26 15:56:57 -05:00
Dave Richer
0b505b3b4b Merged in feature/IO-3554-Form-Row-Layout (pull request #3053)
feature/feature/IO-3554-Form-Row-Layout - Drawer changes, mask closable changes, missing translation, spinner tip changes
2026-02-25 20:59:04 +00:00
Dave
226cc801ae feature/feature/IO-3554-Form-Row-Layout - Drawer changes, mask closable changes, missing translation, spinner tip changes 2026-02-25 15:57:51 -05:00
Dave Richer
67396afeb7 Merged in feature/IO-3554-Form-Row-Layout (pull request #3051)
feature/feature/IO-3554-Form-Row-Layout - Fix search, bump deps, fix formlayout
2026-02-25 20:32:13 +00:00
Dave
dab66b4d66 feature/feature/IO-3554-Form-Row-Layout - Fix search, bump deps, fix formlayout 2026-02-25 15:31:42 -05:00
Dave Richer
20d51431e7 Merged in feature/IO-3554-Form-Row-Layout (pull request #3049)
feature/feature/IO-3554-Form-Row-Layout - Fix search, bump deps, fix formlayout
2026-02-25 20:23:01 +00:00
Dave
15bb1e72a2 feature/feature/IO-3554-Form-Row-Layout - Fix search, bump deps, fix formlayout 2026-02-25 15:22:39 -05:00
Dave Richer
5edab6d040 Merged in feature/IO-3554-Form-Row-Layout (pull request #3047)
feature/feature/IO-3554-Form-Row-Layout - Fix search, bump deps, fix formlayout
2026-02-25 20:04:19 +00:00
Dave
48017e7471 feature/feature/IO-3554-Form-Row-Layout - Fix search, bump deps, fix formlayout 2026-02-25 15:03:53 -05:00
Dave Richer
acb1cc6367 Merged in feature/IO-3554-Form-Row-Layout (pull request #3004)
Responsive Part 1 - Form Layout
2026-02-25 19:48:47 +00:00
Dave
77befd5d93 feature/feature/IO-3554-Form-Row-Layout - Fix search, bump deps, fix formlayout 2026-02-25 14:47:49 -05:00
Dave
c93b8ed961 Merge remote-tracking branch 'origin/release/2026-02-27' into feature/IO-3554-Form-Row-Layout 2026-02-25 13:56:40 -05:00
Allan Carr
4d58c46a33 Merged in feature/IO-3578-Fortellis-Regex-Fix (pull request #3041)
IO-3578 Fortellis Regex Fix
2026-02-25 00:30:33 +00:00
Dave Richer
7299020bd8 Merged in release/2026-02-27 (pull request #3040)
Release/2026 02 27
2026-02-24 18:08:52 +00:00
Dave
f16a0c491b Merge remote-tracking branch 'origin/feature/IO-3537-Bill-Entry-Scroll-to-Top-for-Errors' into release/2026-02-27 2026-02-24 11:08:46 -05:00
Allan Carr
ae52f12bae Merged in feature/IO-3560-Part-Number-on-Return-Item-Modal (pull request #3035)
IO-3560 Part # on Return Item Modal

Approved-by: Dave Richer
2026-02-24 15:26:39 +00:00
Allan Carr
11475afdb1 Merged in feature/IO-3573-Enhanced-Payroll-Labor-Allocations (pull request #3034)
IO-3573 Enhanced Payroll Labor Allocations

Approved-by: Dave Richer
2026-02-24 15:26:07 +00:00
Allan Carr
7a5e722ec1 Merged in feature/IO-3575-Flat-Rate-ATS-PST-Exempt (pull request #3033)
IO-3575 Extend Audit Trail for Tax Rates and Flat ATS

Approved-by: Dave Richer
2026-02-24 15:25:40 +00:00
Allan Carr
7c686e38da Merge branch 'release/2026-02-27' into feature/IO-3537-Bill-Entry-Scroll-to-Top-for-Errors
Signed-off-by: Allan Carr <allan@imexsystems.ca>

# Conflicts:
#	client/src/components/bill-enter-modal/bill-enter-modal.container.jsx
2026-02-23 23:56:28 -08:00
Allan Carr
9eaf45ac88 IO-3537 Bill Entry Scroll to Top for Errors
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-23 23:44:24 -08:00
Allan Carr
8cd2e65305 IO-3560 Part # on Return Item Modal
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-23 19:09:35 -08:00
Allan Carr
da9744da6f IO-3573 Enhanced Payroll Labor Allocation
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-23 18:33:57 -08:00
Allan Carr
947ded4b5e IO-3573 Enhanced Payroll Labor Allocations
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-23 18:20:57 -08:00
Allan Carr
6e6304124b IO-3575 Extend Audit Trail for Tax Rates and Flat ATS
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-23 17:59:39 -08:00
Allan Carr
2f694c2638 Merged in feature/IO-3576-Fortellis-Refetch-Make-Model (pull request #3029)
IO-3576 Fortellis Refetch Make Model
2026-02-23 23:27:41 +00:00
Patrick Fic
5f8a08b0a7 IO-3515 Limit logging. 2026-02-23 11:55:22 -08:00
Patrick Fic
fd7970df2c Merge branch 'feature/IO-3515-ocr-bill-posting' into release/2026-02-27 2026-02-23 11:28:45 -08:00
Patrick Fic
03ad66b2a2 IO-3515 PR Comments addressed. 2026-02-20 09:06:11 -08:00
Patrick Fic
6f80e6dcbf IO-3515 fix notifications & auto attach document. 2026-02-19 15:36:40 -08:00
Patrick Fic
21f43285bc IO-3515 additional cleanup, translations 2026-02-19 14:15:57 -08:00
Patrick Fic
b2bc19c5c9 IO-3515 Add translations and logging. 2026-02-19 13:54:39 -08:00
Allan Carr
e075361e23 Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3025)
IO-3570 Strip - from Owner Name in regex
2026-02-19 21:17:44 +00:00
Allan Carr
a6327912ab Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3021)
IO-3570 Fortellis Owner Phone Search
2026-02-19 20:35:41 +00:00
Patrick Fic
ae1408012f IO-3515 resolve issues on search selects not updating, improve confidence scoring. 2026-02-19 12:22:35 -08:00
Allan Carr
c8b7d7461a Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3019)
Feature/IO-3570 Fortellis Multiple Veh Records
2026-02-19 20:03:48 +00:00
Allan Carr
48755dfa58 Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3017)
Feature/IO-3570 Fortellis Multiple Veh Records
2026-02-19 18:53:47 +00:00
Allan Carr
3be344b595 Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3012)
IO-3570 Filter Vehicle Results from VIN Query to records that only have a vehicleVehID
2026-02-19 17:56:50 +00:00
Patrick Fic
5d53d09af9 IO-3515 set po context, update confidence UI showing 2026-02-18 11:57:56 -08:00
Patrick Fic
d4bbdd7383 IO-3515 Improve confidence display. 2026-02-18 10:56:51 -08:00
Dave Richer
8b55df8624 Merged in feature/IO-3544-Ant-Select-Deprecation (pull request #3010)
feature/IO-3544-Ant-Select-Deprecation - Make cdklike-dms-post-form.jsx compatible with search on the payer name
2026-02-18 18:51:37 +00:00
Dave
8422ea83ae feature/IO-3544-Ant-Select-Deprecation - Make cdklike-dms-post-form.jsx compatible with search on the payer name 2026-02-18 13:50:46 -05:00
Patrick Fic
e5f930b8c8 IO-3515 Refactor button to separate component. 2026-02-18 10:32:44 -08:00
Patrick Fic
6d94265081 Package lock updates. 2026-02-18 10:08:28 -08:00
Patrick Fic
d9e75fe775 Merge branch 'master-AIO' into feature/IO-3515-ocr-bill-posting 2026-02-18 10:08:25 -08:00
Dave Richer
94c3ab6e1b Merged in feature/IO-3544-Ant-Select-Deprecation (pull request #3005)
Feature/IO-3544 Ant Select Deprecation
2026-02-18 17:41:33 +00:00
Dave
1b84087ef8 feature/IO-3544-Ant-Select-Deprecation - Package Bumps 2026-02-18 12:31:55 -05:00
Dave
a9fdf3da18 Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3544-Ant-Select-Deprecation 2026-02-18 12:25:42 -05:00
Dave Richer
6ae4e228ce Merged in release/2026-02-13 (pull request #3001)
Release/2026 02 13
2026-02-13 00:33:10 +00:00
Dave Richer
49fb2caac0 Merged in release/2026-02-13 (pull request #3000)
Release/2026 02 13
2026-02-13 00:32:41 +00:00
Dave
673670eeb4 Merge remote-tracking branch 'origin/release/2026-02-13' into feature/IO-3544-Ant-Select-Deprecation
# Conflicts:
#	client/src/components/jobs-convert-button/jobs-convert-button.component.jsx
2026-02-11 18:23:02 -05:00
Dave Richer
d9b3730db9 Merged in release/2026-02-13 (pull request #2992)
feature/IO-3558-Reynolds-Part-2 - Admin Panel
2026-02-11 23:14:02 +00:00
Dave Richer
313a90e8f3 Merged in release/2026-02-13 (pull request #2989)
Release/2026 02 13
2026-02-11 23:11:42 +00:00
Dave
2a352b60a0 Merge remote-tracking branch 'origin/release/2026-02-13' into feature/IO-3554-Form-Row-Layout 2026-02-11 10:15:56 -05:00
Dave
e6100851b8 feature/IO-3544-Ant-Select-Deprecation - Packages 2026-02-11 10:14:55 -05:00
Dave
e9795072d5 Merge remote-tracking branch 'origin/release/2026-02-13' into feature/IO-3544-Ant-Select-Deprecation 2026-02-11 10:05:58 -05:00
Patrick Fic
64454dce2a IO-3515 add client side polling for now, cost centers. 2026-02-10 11:59:53 -08:00
Patrick Fic
c59acb1b72 IO-3515 add confidence scoring 2026-02-09 14:47:20 -08:00
Dave Richer
773f3d4c84 Merged in release/2026-02-13 (pull request #2969)
IO-3533 Actual Cost Click to Focus
2026-02-06 19:54:36 +00:00
Dave
5ae0e8e4d5 Initial 2026-02-06 10:43:58 -05:00
Dave
40d5e02415 feature/IO-3feature/IO-3544-Ant-Select-Deprecation: Dep Bumps 2026-02-05 13:55:50 -05:00
Dave
5b891281d1 Merge remote-tracking branch 'origin/release/2026-02-13' into feature/IO-3544-Ant-Select-Deprecation
# Conflicts:
#	client/src/components/bill-form/bill-form.lines.component.jsx
2026-02-03 16:52:36 -05:00
Dave
71043313d6 feature/IO-3544-Ant-Select-Deprecation - Fix filtering 2026-02-03 12:40:01 -05:00
Dave
c9620a3f6f feature/IO-3544-Ant-Select-Deprecation - Fix filtering 2026-02-03 11:10:44 -05:00
Dave
cdfae5a429 feature/IO-3544-Ant-Select-Deprecation - finish 2026-02-03 10:51:14 -05:00
Patrick Fic
20dad2caba IO-3515 Minimally functional form fill out. 2026-01-29 16:26:16 -08:00
Patrick Fic
96731a29e1 Remove test data. 2026-01-28 16:23:15 -08:00
Patrick Fic
83be45a40b IO-3515 Checkin. Crude form update with some correct values. Pricing still significantly out. 2026-01-28 16:20:27 -08:00
Patrick Fic
55de16281d IO-3515 Bill OCR refactor to split files and introduce generator. 2026-01-28 14:32:11 -08:00
Patrick Fic
ad7e85a578 IO-3515 bifurcate single/multi page extract, add check for polling, add field labels 2026-01-27 15:40:13 -08:00
Patrick Fic
2a6d0446f0 IO-3515 WIP - bulk calls functioning. Further refinement required. 2026-01-26 16:09:58 -08:00
Patrick Fic
c3718fff87 IO-3515 Additional packages and initial route &n simple queue polling. 2026-01-23 15:04:24 -08:00
421 changed files with 49803 additions and 12914 deletions

15
.gitignore vendored
View File

@@ -129,7 +129,11 @@ vitest-coverage/
test-output.txt
server/job/test/fixtures
# Keep .github ignored by default, but track Copilot instructions
.github
!.github/
.github/*
!.github/copilot-instructions.md
_reference/ragmate/.ragmate.env
docker_data
/.cursorrules
@@ -138,4 +142,15 @@ docker_data
/CLAUDE.md
/COPILOT.md
/GEMINI.md
/.cursorrules
/AGENTS.md
/AI_CONTEXT.md
/CLAUDE.md
/COPILOT.md
/.github/copilot-instructions.md
/GEMINI.md
/_reference/select-component-test-plan.md
.terraform
terraform.tfvars

View File

@@ -1,7 +1,62 @@
This will connect to your dockers local stack session and render the email in HTML.
This app connects to your Docker LocalStack endpoints and gives you a compact inspector for:
- SES generated emails
- CloudWatch log groups, streams, and recent events
- Secrets Manager secrets and values
- S3 buckets and object previews
```shell
npm start
```
Or:
```shell
node index.js
```
http://localhost:3334
Open: http://localhost:3334
Features:
- SES email workspace with manual refresh, live refresh, search, HTML/text/raw views,
attachment downloads, and new-message highlighting
- CloudWatch Logs workspace with log group selection, stream filtering, adjustable time window,
adjustable event limit, live refresh, in-browser log search, log-level highlighting, wrap toggle,
and optional tail-to-newest mode
- Secrets Manager workspace with live refresh, search, expandable secret metadata, lazy-loaded
secret values, masked-by-default secret viewing, and quick copy actions
- S3 Explorer workspace with bucket selection, prefix filtering, object search, lazy object
previews,
object key/URI copy actions, and downloads
- Shared LocalStack service health strip plus a reset action for clearing saved viewer state
- Compact single-page UI for switching between the local stack tools you use most
Code layout:
- `index.js`: small Express bootstrap and route registration
- `server/config.js`: LocalStack endpoints, defaults, and AWS client setup
- `server/localstack-service.js`: SES, Logs, Secrets, and S3 data loading helpers
- `server/page.js`: server-rendered HTML shell, CSS, and client config payload
- `public/client-app.js`: browser-side UI state, rendering, refresh logic, and interactions
Optional environment variables:
```shell
PORT=3334
SES_VIEWER_ENDPOINT=http://localhost:4566/_aws/ses
SES_VIEWER_REFRESH_MS=10000
SES_VIEWER_FETCH_TIMEOUT_MS=5000
CLOUDWATCH_VIEWER_ENDPOINT=http://localhost:4566
CLOUDWATCH_VIEWER_REGION=ca-central-1
CLOUDWATCH_VIEWER_LOG_GROUP=development
CLOUDWATCH_VIEWER_WINDOW_MS=900000
CLOUDWATCH_VIEWER_LIMIT=200
SECRETS_VIEWER_ENDPOINT=http://localhost:4566
SECRETS_VIEWER_REGION=ca-central-1
S3_VIEWER_ENDPOINT=http://localhost:4566
S3_VIEWER_REGION=ca-central-1
S3_VIEWER_BUCKET=
S3_VIEWER_PREVIEW_BYTES=262144
S3_VIEWER_IMAGE_PREVIEW_BYTES=1048576
```

View File

@@ -1,96 +1,342 @@
// index.js
import express from "express";
import fetch from "node-fetch";
import { simpleParser } from "mailparser";
import { readFileSync } from "node:fs";
import {
CLOUDWATCH_DEFAULT_LIMIT,
CLOUDWATCH_DEFAULT_WINDOW_MS,
CLOUDWATCH_ENDPOINT,
CLOUDWATCH_REGION,
DEFAULT_REFRESH_MS,
PORT,
S3_ENDPOINT,
S3_REGION,
SES_ENDPOINT,
SECRETS_ENDPOINT,
SECRETS_REGION
} from "./server/config.js";
import { getClientConfig, renderHtml } from "./server/page.js";
import {
buildAttachmentDisposition,
buildInlineDisposition,
clampNumber,
findSesMessageById,
loadLogEvents,
loadLogGroups,
loadLogStreams,
loadMessageAttachment,
loadMessages,
loadS3Buckets,
loadS3ObjectDownload,
loadS3ObjectPreview,
loadS3Objects,
loadSecretValue,
loadSecrets,
loadServiceHealthSummary
} from "./server/localstack-service.js";
const app = express();
const PORT = 3334;
const CLIENT_APP_PATH = new URL("./public/client-app.js", import.meta.url);
const CLIENT_APP_SOURCE = readFileSync(CLIENT_APP_PATH, "utf8");
app.get("/", async (req, res) => {
app.use((req, res, next) => {
res.set("Cache-Control", "no-store");
next();
});
app.get("/", (req, res) => {
res.type("html").send(renderHtml());
});
app.get("/app.js", (req, res) => {
res.type("application/javascript").send(`${CLIENT_APP_SOURCE}\n\nclientApp(${JSON.stringify(getClientConfig())});\n`);
});
app.get("/health", (req, res) => {
res.json({
ok: true,
endpoint: SES_ENDPOINT,
endpoints: {
ses: SES_ENDPOINT,
cloudWatchLogs: CLOUDWATCH_ENDPOINT,
secretsManager: SECRETS_ENDPOINT,
s3: S3_ENDPOINT
},
port: PORT,
defaultRefreshMs: DEFAULT_REFRESH_MS
});
});
app.get("/api/service-health", async (req, res) => {
try {
const response = await fetch("http://localhost:4566/_aws/ses");
if (!response.ok) {
throw new Error("Network response was not ok");
}
const data = await response.json();
const messagesHtml = await parseMessages(data.messages);
res.send(renderHtml(messagesHtml));
res.json(await loadServiceHealthSummary());
} catch (error) {
console.error("Error fetching messages:", error);
res.status(500).send("Error fetching messages");
console.error("Error fetching service health:", error);
res.status(502).json({
error: "Unable to fetch LocalStack service health",
details: error.message
});
}
});
async function parseMessages(messages) {
const parsedMessages = await Promise.all(
messages.map(async (message, index) => {
try {
const parsed = await simpleParser(message.RawData);
return `
<div class="shadow-md rounded-lg p-4 mb-6" style="background-color: lightgray">
<div class="shadow-md rounded-lg p-4 mb-6" style="background-color: white">
<div class="mb-2"><span class="font-bold text-lg">Message ${index + 1}</span></div>
<div class="mb-2"><span class="font-semibold">From:</span> ${message.Source}</div>
<div class="mb-2"><span class="font-semibold">To:</span> ${parsed.to.text || "No To Address"}</div>
<div class="mb-2"><span class="font-semibold">Subject:</span> ${parsed.subject || "No Subject"}</div>
<div class="mb-2"><span class="font-semibold">Region:</span> ${message.Region}</div>
<div class="mb-2"><span class="font-semibold">Timestamp:</span> ${message.Timestamp}</div>
</div>
<div class="prose">${parsed.html || parsed.textAsHtml || "No HTML content available"}</div>
</div>
`;
} catch (error) {
console.error("Error parsing email:", error);
return `
<div class="bg-white shadow-md rounded-lg p-4 mb-6">
<div class="mb-2"><span class="font-bold text-lg">Message ${index + 1}</span></div>
<div class="mb-2"><span class="font-semibold">From:</span> ${message.Source}</div>
<div class="mb-2"><span class="font-semibold">Region:</span> ${message.Region}</div>
<div class="mb-2"><span class="font-semibold">Timestamp:</span> ${message.Timestamp}</div>
<div class="text-red-500">Error parsing email content</div>
</div>
`;
}
})
);
return parsedMessages.join("");
}
app.get("/api/messages", async (req, res) => {
try {
res.json(await loadMessages());
} catch (error) {
console.error("Error fetching messages:", error);
res.status(502).json({
error: "Unable to fetch messages from LocalStack SES",
details: error.message,
endpoint: SES_ENDPOINT
});
}
});
function renderHtml(messagesHtml) {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Messages Viewer</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
background-color: #f3f4f6;
font-family: Arial, sans-serif;
}
.container {
max-width: 800px;
margin: 50px auto;
padding: 20px;
}
.prose {
line-height: 1.6;
}
</style>
</head>
<body>
<div class="container bg-white shadow-lg rounded-lg p-6">
<h1 class="text-2xl font-bold text-center mb-6">Email Messages Viewer</h1>
<div id="messages-container">${messagesHtml}</div>
</div>
</body>
</html>
`;
}
app.get("/api/messages/:id/raw", async (req, res) => {
try {
const message = await findSesMessageById(req.params.id);
if (!message) {
res.status(404).type("text/plain").send("Message not found");
return;
}
res.type("text/plain").send(message.RawData || "");
} catch (error) {
console.error("Error fetching raw message:", error);
res.status(502).type("text/plain").send(`Unable to fetch raw message: ${error.message}`);
}
});
app.get("/api/messages/:id/attachments/:index", async (req, res) => {
try {
const attachmentIndex = Number.parseInt(req.params.index, 10);
if (!Number.isInteger(attachmentIndex) || attachmentIndex < 0) {
res.status(400).type("text/plain").send("Attachment index must be a non-negative integer");
return;
}
const attachment = await loadMessageAttachment(req.params.id, attachmentIndex);
if (!attachment) {
res.status(404).type("text/plain").send("Attachment not found");
return;
}
res.setHeader("Content-Type", attachment.contentType);
res.setHeader("Content-Disposition", buildAttachmentDisposition(attachment.filename));
res.setHeader("Content-Length", String(attachment.content.length));
res.send(attachment.content);
} catch (error) {
console.error("Error downloading attachment:", error);
res.status(502).type("text/plain").send(`Unable to download attachment: ${error.message}`);
}
});
app.get("/api/logs/groups", async (req, res) => {
try {
const groups = await loadLogGroups();
res.json({
endpoint: CLOUDWATCH_ENDPOINT,
region: CLOUDWATCH_REGION,
groups
});
} catch (error) {
console.error("Error fetching log groups:", error);
res.status(502).json({
error: "Unable to fetch CloudWatch log groups from LocalStack",
details: error.message,
endpoint: CLOUDWATCH_ENDPOINT
});
}
});
app.get("/api/logs/streams", async (req, res) => {
try {
const logGroupName = String(req.query.group || "");
if (!logGroupName) {
res.status(400).json({ error: "Query parameter 'group' is required" });
return;
}
res.json({
logGroupName,
streams: await loadLogStreams(logGroupName)
});
} catch (error) {
console.error("Error fetching log streams:", error);
res.status(502).json({
error: "Unable to fetch CloudWatch log streams from LocalStack",
details: error.message,
endpoint: CLOUDWATCH_ENDPOINT
});
}
});
app.get("/api/logs/events", async (req, res) => {
try {
const logGroupName = String(req.query.group || "");
const logStreamName = String(req.query.stream || "");
const windowMs = clampNumber(req.query.windowMs, CLOUDWATCH_DEFAULT_WINDOW_MS, 60 * 1000, 24 * 60 * 60 * 1000);
const limit = clampNumber(req.query.limit, CLOUDWATCH_DEFAULT_LIMIT, 25, 500);
if (!logGroupName) {
res.status(400).json({ error: "Query parameter 'group' is required" });
return;
}
res.json(await loadLogEvents({ logGroupName, logStreamName, windowMs, limit }));
} catch (error) {
console.error("Error fetching log events:", error);
res.status(502).json({
error: "Unable to fetch CloudWatch log events from LocalStack",
details: error.message,
endpoint: CLOUDWATCH_ENDPOINT
});
}
});
app.get("/api/secrets", async (req, res) => {
try {
res.json(await loadSecrets());
} catch (error) {
console.error("Error fetching secrets:", error);
res.status(502).json({
error: "Unable to fetch Secrets Manager secrets from LocalStack",
details: error.message,
endpoint: SECRETS_ENDPOINT
});
}
});
app.get("/api/secrets/value", async (req, res) => {
try {
const secretId = String(req.query.id || "");
if (!secretId) {
res.status(400).json({ error: "Query parameter 'id' is required" });
return;
}
res.json(await loadSecretValue(secretId));
} catch (error) {
if (error?.name === "ResourceNotFoundException") {
res.status(404).json({
error: "Secret not found",
details: error.message,
endpoint: SECRETS_ENDPOINT
});
return;
}
console.error("Error fetching secret value:", error);
res.status(502).json({
error: "Unable to fetch Secrets Manager value from LocalStack",
details: error.message,
endpoint: SECRETS_ENDPOINT
});
}
});
app.get("/api/s3/buckets", async (req, res) => {
try {
res.json(await loadS3Buckets());
} catch (error) {
console.error("Error fetching S3 buckets:", error);
res.status(502).json({
error: "Unable to fetch S3 buckets from LocalStack",
details: error.message,
endpoint: S3_ENDPOINT
});
}
});
app.get("/api/s3/objects", async (req, res) => {
try {
const bucket = String(req.query.bucket || "");
const prefix = String(req.query.prefix || "");
if (!bucket) {
res.status(400).json({ error: "Query parameter 'bucket' is required" });
return;
}
res.json(await loadS3Objects({ bucket, prefix }));
} catch (error) {
console.error("Error fetching S3 objects:", error);
res.status(502).json({
error: "Unable to fetch S3 objects from LocalStack",
details: error.message,
endpoint: S3_ENDPOINT
});
}
});
app.get("/api/s3/object", async (req, res) => {
try {
const bucket = String(req.query.bucket || "");
const key = String(req.query.key || "");
if (!bucket || !key) {
res.status(400).json({ error: "Query parameters 'bucket' and 'key' are required" });
return;
}
res.json(await loadS3ObjectPreview({ bucket, key }));
} catch (error) {
if (error?.name === "NoSuchKey" || error?.name === "NotFound") {
res.status(404).json({
error: "Object not found",
details: error.message,
endpoint: S3_ENDPOINT
});
return;
}
console.error("Error fetching S3 object preview:", error);
res.status(502).json({
error: "Unable to fetch S3 object preview from LocalStack",
details: error.message,
endpoint: S3_ENDPOINT
});
}
});
app.get("/api/s3/download", async (req, res) => {
try {
const bucket = String(req.query.bucket || "");
const key = String(req.query.key || "");
const inline = String(req.query.inline || "") === "1";
if (!bucket || !key) {
res.status(400).type("text/plain").send("Query parameters 'bucket' and 'key' are required");
return;
}
const object = await loadS3ObjectDownload({ bucket, key });
res.setHeader("Content-Type", object.contentType);
res.setHeader(
"Content-Disposition",
inline ? buildInlineDisposition(object.filename) : buildAttachmentDisposition(object.filename)
);
res.setHeader("Content-Length", String(object.content.length));
res.send(object.content);
} catch (error) {
if (error?.name === "NoSuchKey" || error?.name === "NotFound") {
res.status(404).type("text/plain").send("Object not found");
return;
}
console.error("Error downloading S3 object:", error);
res.status(502).type("text/plain").send(`Unable to download S3 object: ${error.message}`);
}
});
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
console.log(`LocalStack inspector is running on http://localhost:${PORT}`);
console.log(`Watching LocalStack SES endpoint at ${SES_ENDPOINT}`);
console.log(`Watching LocalStack CloudWatch Logs endpoint at ${CLOUDWATCH_ENDPOINT} (${CLOUDWATCH_REGION})`);
console.log(`Watching LocalStack Secrets Manager endpoint at ${SECRETS_ENDPOINT} (${SECRETS_REGION})`);
console.log(`Watching LocalStack S3 endpoint at ${S3_ENDPOINT} (${S3_REGION})`);
});

File diff suppressed because it is too large Load Diff

View File

@@ -4,13 +4,17 @@
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"start": "node index.js",
"check": "node --check index.js && node --check public/client-app.js && node --check server/config.js && node --check server/localstack-service.js && node --check server/page.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"description": "LocalStack inspector for SES emails, CloudWatch logs, Secrets Manager, and S3",
"dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.1012.0",
"@aws-sdk/client-s3": "^3.1013.0",
"@aws-sdk/client-secrets-manager": "^3.1013.0",
"express": "^5.1.0",
"mailparser": "^3.7.4",
"node-fetch": "^3.3.2"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs";
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
import { S3Client } from "@aws-sdk/client-s3";
export const PORT = Number(process.env.PORT || 3334);
export const SES_ENDPOINT = process.env.SES_VIEWER_ENDPOINT || "http://localhost:4566/_aws/ses";
export const FETCH_TIMEOUT_MS = Number(process.env.SES_VIEWER_FETCH_TIMEOUT_MS || 5000);
export const DEFAULT_REFRESH_MS = Number(process.env.SES_VIEWER_REFRESH_MS || 10000);
export const CLOUDWATCH_ENDPOINT = process.env.CLOUDWATCH_VIEWER_ENDPOINT || "http://localhost:4566";
export const CLOUDWATCH_REGION =
process.env.CLOUDWATCH_VIEWER_REGION || process.env.AWS_DEFAULT_REGION || "ca-central-1";
export const CLOUDWATCH_DEFAULT_GROUP = process.env.CLOUDWATCH_VIEWER_LOG_GROUP || "development";
export const CLOUDWATCH_DEFAULT_WINDOW_MS = Number(process.env.CLOUDWATCH_VIEWER_WINDOW_MS || 15 * 60 * 1000);
export const CLOUDWATCH_DEFAULT_LIMIT = Number(process.env.CLOUDWATCH_VIEWER_LIMIT || 200);
export const SECRETS_ENDPOINT = process.env.SECRETS_VIEWER_ENDPOINT || CLOUDWATCH_ENDPOINT;
export const SECRETS_REGION = process.env.SECRETS_VIEWER_REGION || CLOUDWATCH_REGION;
export const S3_ENDPOINT = process.env.S3_VIEWER_ENDPOINT || CLOUDWATCH_ENDPOINT;
export const S3_REGION = process.env.S3_VIEWER_REGION || CLOUDWATCH_REGION;
export const S3_DEFAULT_BUCKET = process.env.S3_VIEWER_BUCKET || "";
export const S3_PREVIEW_MAX_BYTES = Number(process.env.S3_VIEWER_PREVIEW_BYTES || 256 * 1024);
export const S3_IMAGE_PREVIEW_MAX_BYTES = Number(process.env.S3_VIEWER_IMAGE_PREVIEW_BYTES || 1024 * 1024);
export const LOCALSTACK_CREDENTIALS = {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "test",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "test"
};
export const cloudWatchLogsClient = new CloudWatchLogsClient({
region: CLOUDWATCH_REGION,
endpoint: CLOUDWATCH_ENDPOINT,
credentials: LOCALSTACK_CREDENTIALS
});
export const secretsManagerClient = new SecretsManagerClient({
region: SECRETS_REGION,
endpoint: SECRETS_ENDPOINT,
credentials: LOCALSTACK_CREDENTIALS
});
export const s3Client = new S3Client({
region: S3_REGION,
endpoint: S3_ENDPOINT,
credentials: LOCALSTACK_CREDENTIALS,
forcePathStyle: true
});

View File

@@ -0,0 +1,845 @@
import fetch from "node-fetch";
import {
DescribeLogGroupsCommand,
DescribeLogStreamsCommand,
FilterLogEventsCommand
} from "@aws-sdk/client-cloudwatch-logs";
import { GetSecretValueCommand, ListSecretsCommand } from "@aws-sdk/client-secrets-manager";
import { GetObjectCommand, HeadObjectCommand, ListBucketsCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
import { simpleParser } from "mailparser";
import {
CLOUDWATCH_ENDPOINT,
CLOUDWATCH_REGION,
FETCH_TIMEOUT_MS,
S3_ENDPOINT,
S3_IMAGE_PREVIEW_MAX_BYTES,
S3_PREVIEW_MAX_BYTES,
S3_REGION,
SES_ENDPOINT,
SECRETS_ENDPOINT,
SECRETS_REGION,
cloudWatchLogsClient,
s3Client,
secretsManagerClient
} from "./config.js";
async function loadMessages() {
const startedAt = Date.now();
const sesMessages = await fetchSesMessages();
const messages = await Promise.all(sesMessages.map((message, index) => toMessageViewModel(message, index)));
messages.sort((left, right) => {
if ((right.timestampMs || 0) !== (left.timestampMs || 0)) {
return (right.timestampMs || 0) - (left.timestampMs || 0);
}
return right.index - left.index;
});
return {
endpoint: SES_ENDPOINT,
fetchedAt: new Date().toISOString(),
fetchDurationMs: Date.now() - startedAt,
totalMessages: messages.length,
parseErrors: messages.filter((message) => Boolean(message.parseError)).length,
latestMessageTimestamp: messages[0]?.timestamp || "",
messages
};
}
async function fetchSesMessages() {
const response = await fetch(SES_ENDPOINT, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
});
if (!response.ok) {
throw new Error(`SES endpoint responded with ${response.status}`);
}
const data = await response.json();
return Array.isArray(data.messages) ? data.messages : [];
}
async function loadLogGroups() {
const groups = [];
let nextToken;
let pageCount = 0;
do {
const response = await cloudWatchLogsClient.send(
new DescribeLogGroupsCommand({
nextToken,
limit: 50
})
);
groups.push(
...(response.logGroups || []).map((group) => ({
name: group.logGroupName || "",
arn: group.arn || "",
storedBytes: group.storedBytes || 0,
retentionInDays: group.retentionInDays || 0,
creationTime: group.creationTime || 0
}))
);
nextToken = response.nextToken;
pageCount += 1;
} while (nextToken && pageCount < 10);
return groups.sort((left, right) => left.name.localeCompare(right.name));
}
async function loadLogStreams(logGroupName) {
const streams = [];
let nextToken;
let pageCount = 0;
do {
const response = await cloudWatchLogsClient.send(
new DescribeLogStreamsCommand({
logGroupName,
descending: true,
orderBy: "LastEventTime",
nextToken,
limit: 50
})
);
streams.push(
...(response.logStreams || []).map((stream) => ({
name: stream.logStreamName || "",
arn: stream.arn || "",
lastEventTimestamp: stream.lastEventTimestamp || 0,
lastIngestionTime: stream.lastIngestionTime || 0,
storedBytes: stream.storedBytes || 0
}))
);
nextToken = response.nextToken;
pageCount += 1;
} while (nextToken && pageCount < 6 && streams.length < 250);
return streams;
}
async function loadLogEvents({ logGroupName, logStreamName, windowMs, limit }) {
const startedAt = Date.now();
const eventMap = new Map();
const startTime = Date.now() - windowMs;
let nextToken;
let previousToken = "";
let pageCount = 0;
let searchedLogStreams = 0;
do {
const response = await cloudWatchLogsClient.send(
new FilterLogEventsCommand({
logGroupName,
logStreamNames: logStreamName ? [logStreamName] : undefined,
startTime,
endTime: Date.now(),
limit,
nextToken
})
);
for (const event of response.events || []) {
const id =
event.eventId || `${event.logStreamName || "stream"}-${event.timestamp || 0}-${event.ingestionTime || 0}`;
if (!eventMap.has(id)) {
const message = String(event.message || "").trim();
eventMap.set(id, {
id,
timestamp: event.timestamp || 0,
ingestionTime: event.ingestionTime || 0,
logStreamName: event.logStreamName || "",
message,
preview: buildLogPreview(message)
});
}
}
searchedLogStreams = Math.max(searchedLogStreams, (response.searchedLogStreams || []).length);
previousToken = nextToken || "";
nextToken = response.nextToken;
pageCount += 1;
} while (nextToken && nextToken !== previousToken && pageCount < 10 && eventMap.size < limit);
const events = [...eventMap.values()]
.sort((left, right) => {
if ((right.timestamp || 0) !== (left.timestamp || 0)) {
return (right.timestamp || 0) - (left.timestamp || 0);
}
return left.logStreamName.localeCompare(right.logStreamName);
})
.slice(0, limit);
return {
endpoint: CLOUDWATCH_ENDPOINT,
region: CLOUDWATCH_REGION,
logGroupName,
logStreamName,
fetchDurationMs: Date.now() - startedAt,
latestTimestamp: events[0]?.timestamp || 0,
searchedLogStreams,
totalEvents: events.length,
events
};
}
async function loadSecrets() {
const startedAt = Date.now();
const secrets = [];
let nextToken;
let pageCount = 0;
do {
const response = await secretsManagerClient.send(
new ListSecretsCommand({
NextToken: nextToken,
MaxResults: 50
})
);
secrets.push(
...(response.SecretList || []).map((secret, index) => ({
id: secret.ARN || secret.Name || `secret-${index}`,
name: secret.Name || "Unnamed secret",
arn: secret.ARN || "",
description: secret.Description || "",
createdDate: normalizeTimestamp(secret.CreatedDate),
lastChangedDate: normalizeTimestamp(secret.LastChangedDate),
lastAccessedDate: normalizeTimestamp(secret.LastAccessedDate),
deletedDate: normalizeTimestamp(secret.DeletedDate),
primaryRegion: secret.PrimaryRegion || "",
owningService: secret.OwningService || "",
rotationEnabled: Boolean(secret.RotationEnabled),
versionCount: Object.keys(secret.SecretVersionsToStages || {}).length,
tagCount: Array.isArray(secret.Tags) ? secret.Tags.length : 0,
tags: (secret.Tags || [])
.map((tag) => ({
key: tag.Key || "",
value: tag.Value || ""
}))
.filter((tag) => tag.key || tag.value)
}))
);
nextToken = response.NextToken;
pageCount += 1;
} while (nextToken && pageCount < 10 && secrets.length < 500);
secrets.sort((left, right) => {
const leftTime = Date.parse(left.lastChangedDate || left.createdDate || 0) || 0;
const rightTime = Date.parse(right.lastChangedDate || right.createdDate || 0) || 0;
if (rightTime !== leftTime) {
return rightTime - leftTime;
}
return left.name.localeCompare(right.name);
});
return {
endpoint: SECRETS_ENDPOINT,
region: SECRETS_REGION,
fetchedAt: new Date().toISOString(),
fetchDurationMs: Date.now() - startedAt,
totalSecrets: secrets.length,
latestTimestamp: secrets[0]?.lastChangedDate || secrets[0]?.createdDate || "",
secrets
};
}
async function loadSecretValue(secretId) {
const startedAt = Date.now();
const response = await secretsManagerClient.send(
new GetSecretValueCommand({
SecretId: secretId
})
);
const secretBinary = response.SecretBinary
? typeof response.SecretBinary === "string"
? response.SecretBinary
: Buffer.from(response.SecretBinary).toString("base64")
: "";
return {
endpoint: SECRETS_ENDPOINT,
region: SECRETS_REGION,
fetchDurationMs: Date.now() - startedAt,
id: secretId,
name: response.Name || "",
arn: response.ARN || "",
versionId: response.VersionId || "",
versionStages: Array.isArray(response.VersionStages) ? response.VersionStages : [],
createdDate: normalizeTimestamp(response.CreatedDate),
secretString: typeof response.SecretString === "string" ? response.SecretString : "",
secretBinary
};
}
async function loadS3Buckets() {
const startedAt = Date.now();
const response = await s3Client.send(new ListBucketsCommand({}));
const buckets = (response.Buckets || [])
.map((bucket) => ({
name: bucket.Name || "",
creationDate: normalizeTimestamp(bucket.CreationDate)
}))
.filter((bucket) => bucket.name)
.sort((left, right) => left.name.localeCompare(right.name));
return {
endpoint: S3_ENDPOINT,
region: S3_REGION,
fetchedAt: new Date().toISOString(),
fetchDurationMs: Date.now() - startedAt,
totalBuckets: buckets.length,
buckets
};
}
async function loadS3Objects({ bucket, prefix }) {
const startedAt = Date.now();
const objects = [];
let continuationToken;
let pageCount = 0;
do {
const response = await s3Client.send(
new ListObjectsV2Command({
Bucket: bucket,
Prefix: prefix || undefined,
ContinuationToken: continuationToken,
MaxKeys: 200
})
);
objects.push(
...(response.Contents || []).map((object, index) => ({
id: `${bucket}::${object.Key || index}`,
bucket,
key: object.Key || "",
size: object.Size || 0,
lastModified: normalizeTimestamp(object.LastModified),
etag: String(object.ETag || "").replace(/^"|"$/g, ""),
storageClass: object.StorageClass || "STANDARD"
}))
);
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
pageCount += 1;
} while (continuationToken && pageCount < 10 && objects.length < 1000);
objects.sort((left, right) => {
const leftTime = Date.parse(left.lastModified || 0) || 0;
const rightTime = Date.parse(right.lastModified || 0) || 0;
if (rightTime !== leftTime) {
return rightTime - leftTime;
}
return left.key.localeCompare(right.key);
});
return {
endpoint: S3_ENDPOINT,
region: S3_REGION,
bucket,
prefix,
fetchedAt: new Date().toISOString(),
fetchDurationMs: Date.now() - startedAt,
totalObjects: objects.length,
latestTimestamp: objects[0]?.lastModified || "",
objects
};
}
async function loadS3ObjectPreview({ bucket, key }) {
const startedAt = Date.now();
const head = await s3Client.send(
new HeadObjectCommand({
Bucket: bucket,
Key: key
})
);
const contentType = head.ContentType || guessObjectContentType(key);
const contentLength = Number(head.ContentLength || 0);
const previewType = resolveS3PreviewType(contentType, key);
const result = {
endpoint: S3_ENDPOINT,
region: S3_REGION,
bucket,
key,
fetchDurationMs: 0,
contentType,
contentLength,
etag: String(head.ETag || "").replace(/^"|"$/g, ""),
lastModified: normalizeTimestamp(head.LastModified),
metadata: head.Metadata || {},
previewType,
previewText: "",
imageDataUrl: "",
truncated: false
};
const shouldLoadTextPreview = previewType === "json" || previewType === "text" || previewType === "html";
const shouldLoadImagePreview =
previewType === "image" && contentLength > 0 && contentLength <= S3_IMAGE_PREVIEW_MAX_BYTES;
if ((shouldLoadTextPreview || shouldLoadImagePreview) && contentLength > 0) {
const previewBytes = Math.max(1, Math.min(contentLength || S3_PREVIEW_MAX_BYTES, S3_PREVIEW_MAX_BYTES));
const response = await s3Client.send(
new GetObjectCommand({
Bucket: bucket,
Key: key,
Range: `bytes=0-${previewBytes - 1}`
})
);
const content = Buffer.from(await response.Body.transformToByteArray());
result.truncated = contentLength > content.length;
if (shouldLoadImagePreview) {
result.imageDataUrl = `data:${contentType};base64,${content.toString("base64")}`;
} else {
result.previewText = content.toString("utf8");
}
}
result.fetchDurationMs = Date.now() - startedAt;
return result;
}
async function loadServiceHealthSummary() {
const startedAt = Date.now();
const [sesResult, logsResult, secretsResult, s3Result] = await Promise.allSettled([
fetchSesMessages(),
loadLogGroups(),
loadSecrets(),
loadS3Buckets()
]);
return {
fetchedAt: new Date().toISOString(),
fetchDurationMs: Date.now() - startedAt,
services: {
emails: summarizeHealthResult({
icon: "✉️",
panel: "emails",
label: "SES Emails",
result: sesResult,
count: sesResult.status === "fulfilled" ? sesResult.value.length : 0,
detail: SES_ENDPOINT,
noun: "email"
}),
logs: summarizeHealthResult({
icon: "📜",
panel: "logs",
label: "CloudWatch Logs",
result: logsResult,
count: logsResult.status === "fulfilled" ? logsResult.value.length : 0,
detail: `${CLOUDWATCH_ENDPOINT} (${CLOUDWATCH_REGION})`,
noun: "group"
}),
secrets: summarizeHealthResult({
icon: "🔐",
panel: "secrets",
label: "Secrets Manager",
result: secretsResult,
count: secretsResult.status === "fulfilled" ? secretsResult.value.totalSecrets : 0,
detail: `${SECRETS_ENDPOINT} (${SECRETS_REGION})`,
noun: "secret"
}),
s3: summarizeHealthResult({
icon: "🪣",
panel: "s3",
label: "S3 Explorer",
result: s3Result,
count: s3Result.status === "fulfilled" ? s3Result.value.totalBuckets : 0,
detail: `${S3_ENDPOINT} (${S3_REGION})`,
noun: "bucket"
})
}
};
}
async function findSesMessageById(id) {
const messages = await fetchSesMessages();
return messages.find((message, index) => resolveMessageId(message, index) === id) || null;
}
async function parseSesMessageById(id) {
const message = await findSesMessageById(id);
if (!message) {
return null;
}
return simpleParser(message.RawData || "");
}
async function toMessageViewModel(message, index) {
const id = resolveMessageId(message, index);
try {
const parsed = await simpleParser(message.RawData || "");
const textContent = normalizeText(parsed.text || "");
const renderedHtml = buildRenderedHtml(parsed.html || parsed.textAsHtml || "");
const timestamp = normalizeTimestamp(message.Timestamp || parsed.date);
return {
id,
index,
from: formatAddressList(parsed.from) || message.Source || "Unknown sender",
to: formatAddressList(parsed.to) || "No To Address",
replyTo: formatAddressList(parsed.replyTo),
subject: parsed.subject || "No Subject",
region: message.Region || "",
timestamp,
timestampMs: timestamp ? Date.parse(timestamp) : 0,
messageId: parsed.messageId || "",
rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"),
attachmentCount: parsed.attachments.length,
attachments: parsed.attachments.map((attachment, attachmentIndex) => ({
index: attachmentIndex,
filename: resolveAttachmentFilename(attachment, attachmentIndex),
contentType: attachment.contentType || "application/octet-stream",
size: attachment.size || 0
})),
preview: buildPreview(textContent, renderedHtml),
textContent,
renderedHtml,
hasHtml: Boolean(renderedHtml),
parseError: ""
};
} catch (error) {
return {
id,
index,
from: message.Source || "Unknown sender",
to: "Unknown recipient",
replyTo: "",
subject: "Unable to parse message",
region: message.Region || "",
timestamp: normalizeTimestamp(message.Timestamp),
timestampMs: message.Timestamp ? Date.parse(message.Timestamp) : 0,
messageId: "",
rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"),
attachmentCount: 0,
attachments: [],
preview: "This message could not be parsed. Open the raw view to inspect the MIME source.",
textContent: "",
renderedHtml: "",
hasHtml: false,
parseError: error.message
};
}
}
function resolveMessageId(message, index = 0) {
return message.Id || `${message.Timestamp || "unknown"}-${message.Source || "unknown"}-${index}`;
}
function resolveAttachmentFilename(attachment, index = 0) {
if (attachment?.filename) {
return attachment.filename;
}
return `attachment-${index + 1}${attachmentExtension(attachment?.contentType)}`;
}
function attachmentExtension(contentType) {
const normalized = String(contentType || "")
.split(";")[0]
.trim()
.toLowerCase();
return (
{
"application/json": ".json",
"application/pdf": ".pdf",
"application/zip": ".zip",
"image/gif": ".gif",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"text/calendar": ".ics",
"text/csv": ".csv",
"text/html": ".html",
"text/plain": ".txt"
}[normalized] || ""
);
}
function buildAttachmentDisposition(filename) {
const fallback = String(filename || "attachment")
.replace(/[^\x20-\x7e]/g, "_")
.replace(/["\\]/g, "_");
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename || "attachment")}`;
}
function buildInlineDisposition(filename) {
const fallback = String(filename || "file")
.replace(/[^\x20-\x7e]/g, "_")
.replace(/["\\]/g, "_");
return `inline; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename || "file")}`;
}
function basenameFromKey(key) {
const value = String(key || "");
const parts = value.split("/").filter(Boolean);
return parts[parts.length - 1] || "file";
}
function guessObjectContentType(key) {
const normalizedKey = String(key || "").toLowerCase();
if (normalizedKey.endsWith(".json")) {
return "application/json";
}
if (normalizedKey.endsWith(".csv")) {
return "text/csv";
}
if (normalizedKey.endsWith(".html") || normalizedKey.endsWith(".htm")) {
return "text/html";
}
if (normalizedKey.endsWith(".txt") || normalizedKey.endsWith(".log") || normalizedKey.endsWith(".md")) {
return "text/plain";
}
if (normalizedKey.endsWith(".png")) {
return "image/png";
}
if (normalizedKey.endsWith(".jpg") || normalizedKey.endsWith(".jpeg")) {
return "image/jpeg";
}
if (normalizedKey.endsWith(".gif")) {
return "image/gif";
}
if (normalizedKey.endsWith(".webp")) {
return "image/webp";
}
if (normalizedKey.endsWith(".svg")) {
return "image/svg+xml";
}
if (normalizedKey.endsWith(".pdf")) {
return "application/pdf";
}
return "application/octet-stream";
}
function resolveS3PreviewType(contentType, key) {
const normalizedType = String(contentType || "").toLowerCase();
const normalizedKey = String(key || "").toLowerCase();
if (normalizedType.includes("json") || normalizedKey.endsWith(".json")) {
return "json";
}
if (normalizedType.startsWith("image/")) {
return "image";
}
if (normalizedType.includes("html") || normalizedKey.endsWith(".html") || normalizedKey.endsWith(".htm")) {
return "html";
}
if (
normalizedType.startsWith("text/") ||
[".txt", ".log", ".csv", ".xml", ".yml", ".yaml", ".md"].some((extension) => normalizedKey.endsWith(extension))
) {
return "text";
}
return "binary";
}
function summarizeHealthResult({ icon, panel, label, result, count, detail, noun }) {
if (result.status === "fulfilled") {
return {
ok: true,
icon,
panel,
label,
count,
summary: `${count} ${noun}${count === 1 ? "" : "s"}`,
detail
};
}
return {
ok: false,
icon,
panel,
label,
count: 0,
summary: "Needs attention",
detail: result.reason?.message || detail
};
}
function normalizeTimestamp(value) {
if (!value) {
return "";
}
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? "" : date.toISOString();
}
function normalizeText(value) {
return String(value || "")
.replace(/\r\n/g, "\n")
.trim();
}
function buildPreview(textContent, renderedHtml) {
const source = (textContent || stripTags(renderedHtml)).replace(/\s+/g, " ").trim();
if (!source) {
return "No message preview available.";
}
return source.length > 220 ? `${source.slice(0, 217)}...` : source;
}
function buildLogPreview(message) {
const source = String(message || "")
.replace(/\s+/g, " ")
.trim();
if (!source) {
return "No log preview available.";
}
return source.length > 220 ? `${source.slice(0, 217)}...` : source;
}
function clampNumber(value, fallback, min, max) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.min(Math.max(parsed, min), max);
}
function buildRenderedHtml(html) {
if (!html) {
return "";
}
const value = String(html);
const hasDocument = /<html[\s>]/i.test(value) || /<!doctype/i.test(value);
if (hasDocument) {
return value;
}
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base target="_blank">
<style>body{margin:0;padding:16px;font-family:Arial,sans-serif;background:#fff;}</style>
</head>
<body>${value}</body>
</html>`;
}
function stripTags(value) {
return String(value || "")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<[^>]+>/g, " ");
}
function formatAddressList(addresses) {
if (!addresses?.value?.length) {
return "";
}
return addresses.value
.map(({ name, address }) => {
if (name && address) {
return `${name} <${address}>`;
}
return address || name || "";
})
.filter(Boolean)
.join(", ");
}
async function loadMessageAttachment(messageId, attachmentIndex) {
const parsed = await parseSesMessageById(messageId);
if (!parsed) {
return null;
}
const attachment = parsed.attachments?.[attachmentIndex];
if (!attachment) {
return null;
}
return {
filename: resolveAttachmentFilename(attachment, attachmentIndex),
contentType: attachment.contentType || "application/octet-stream",
content: Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content || "")
};
}
async function loadS3ObjectDownload({ bucket, key }) {
const response = await s3Client.send(
new GetObjectCommand({
Bucket: bucket,
Key: key
})
);
return {
filename: basenameFromKey(key),
contentType: response.ContentType || guessObjectContentType(key),
content: Buffer.from(await response.Body.transformToByteArray())
};
}
export {
buildAttachmentDisposition,
buildInlineDisposition,
clampNumber,
findSesMessageById,
loadLogEvents,
loadLogGroups,
loadLogStreams,
loadMessageAttachment,
loadMessages,
loadS3Buckets,
loadS3ObjectDownload,
loadS3ObjectPreview,
loadS3Objects,
loadSecretValue,
loadSecrets,
loadServiceHealthSummary
};

View File

@@ -0,0 +1,495 @@
import {
CLOUDWATCH_DEFAULT_GROUP,
CLOUDWATCH_DEFAULT_LIMIT,
CLOUDWATCH_DEFAULT_WINDOW_MS,
CLOUDWATCH_ENDPOINT,
CLOUDWATCH_REGION,
DEFAULT_REFRESH_MS,
S3_DEFAULT_BUCKET,
S3_ENDPOINT,
S3_REGION,
SECRETS_ENDPOINT,
SECRETS_REGION,
SES_ENDPOINT
} from "./config.js";
function getClientConfig() {
return {
defaultRefreshMs: DEFAULT_REFRESH_MS,
endpoint: SES_ENDPOINT,
cloudWatchEndpoint: CLOUDWATCH_ENDPOINT,
cloudWatchRegion: CLOUDWATCH_REGION,
secretsEndpoint: SECRETS_ENDPOINT,
secretsRegion: SECRETS_REGION,
s3Endpoint: S3_ENDPOINT,
s3Region: S3_REGION,
defaultS3Bucket: S3_DEFAULT_BUCKET,
defaultLogGroup: CLOUDWATCH_DEFAULT_GROUP,
defaultLogWindowMs: CLOUDWATCH_DEFAULT_WINDOW_MS,
defaultLogLimit: CLOUDWATCH_DEFAULT_LIMIT
};
}
function renderHtml() {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LocalStack Inspector</title>
<style>${renderStyles()}</style>
</head>
<body>
<div class="page">
<header class="hero">
<div class="heroShell">
<div class="heroIdentity">
<p class="eyebrow">LocalStack Toolbox</p>
<h1>Inspector</h1>
</div>
<div class="heroTopRow">
<div class="heroActions">
<button id="themeToggle" class="ghost themeToggle" type="button" aria-pressed="false">☀️ Light theme</button>
<button id="resetStateButton" class="ghost" type="button">🧹 Reset saved state</button>
</div>
</div>
<div class="heroStatusRow">
<span class="heroStatusLabel">Stack</span>
<div id="healthStrip" class="healthStrip" aria-live="polite"></div>
<button id="healthRefreshButton" class="mini healthRefreshButton" type="button" title="Refresh service health" aria-label="Refresh service health">🩺</button>
</div>
</div>
</header>
<section id="emailsPanel" class="workspacePanel">
<section class="toolControls">
<div class="row">
<button id="refreshButton" class="primary" type="button">🔄 Refresh</button>
<label class="chip"><input id="autoToggle" type="checkbox" checked> Live refresh</label>
<label class="chip">Every
<select id="intervalSelect">
<option value="5000">5s</option>
<option value="10000" selected>10s</option>
<option value="15000">15s</option>
<option value="30000">30s</option>
<option value="60000">60s</option>
</select>
</label>
<span id="statusChip" class="status">Waiting for first refresh...</span>
</div>
<div class="row">
<input id="searchInput" class="search" type="search" placeholder="Search subject, sender, preview..." autocomplete="off">
<button id="clearSearchButton" class="ghost" type="button">Clear</button>
<button id="expandAllButton" class="ghost" type="button">Open all</button>
<button id="collapseAllButton" class="ghost" type="button">Close all</button>
</div>
</section>
<section class="stats">
<article class="stat"><span>Total</span><strong id="totalStat">0</strong><small id="visibleStat">0 visible</small></article>
<article class="stat"><span>New</span><strong id="newStat">0</strong><small>New since last refresh</small></article>
<article class="stat"><span>Newest</span><strong id="newestStat" class="small">No messages</strong><small id="updatedStat">Not refreshed yet</small></article>
<article class="stat"><span>Fetch</span><strong id="fetchStat" class="small">Idle</strong><small id="fetchDetail">Endpoint: ${escapeHtml(SES_ENDPOINT)}</small></article>
</section>
<div id="emailsContentPane" class="contentPane">
<div class="contentStack">
<div id="banner" class="banner" hidden></div>
<div id="empty" class="empty" hidden></div>
<section id="list" class="list" aria-live="polite"></section>
<div class="paneTopWrap">
<button id="scrollToTopButton" class="paneTopButton" type="button" title="Scroll to top" aria-label="Scroll to top">&#8593;</button>
</div>
</div>
</div>
</section>
<section id="logsPanel" class="workspacePanel" hidden>
<section class="toolControls">
<div class="row">
<button id="logsRefreshButton" class="primary" type="button">🔄 Refresh</button>
<label class="chip"><input id="logsAutoToggle" type="checkbox" checked> Live refresh</label>
<label class="chip">Every
<select id="logsIntervalSelect">
<option value="5000">5s</option>
<option value="10000" selected>10s</option>
<option value="15000">15s</option>
<option value="30000">30s</option>
<option value="60000">60s</option>
</select>
</label>
<span id="logsStatusChip" class="status">Waiting for first refresh...</span>
</div>
<div class="row">
<label class="chip">Group
<select id="logsGroupSelect"></select>
</label>
<label class="chip">Stream
<select id="logsStreamSelect"></select>
</label>
<label class="chip">Window
<select id="logsWindowSelect">
<option value="300000">5m</option>
<option value="900000" selected>15m</option>
<option value="3600000">1h</option>
<option value="21600000">6h</option>
<option value="86400000">24h</option>
</select>
</label>
<label class="chip">Limit
<select id="logsLimitSelect">
<option value="100">100</option>
<option value="200" selected>200</option>
<option value="300">300</option>
<option value="500">500</option>
</select>
</label>
</div>
<div class="row">
<input id="logsSearchInput" class="search" type="search" placeholder="Search stream name or log content..." autocomplete="off">
<button id="logsClearSearchButton" class="ghost" type="button">Clear</button>
<label class="chip"><input id="logsWrapToggle" type="checkbox" checked> Wrap lines</label>
<label class="chip"><input id="logsTailToggle" type="checkbox"> Tail newest</label>
<button id="logsExpandAllButton" class="ghost" type="button">Open all</button>
<button id="logsCollapseAllButton" class="ghost" type="button">Close all</button>
</div>
</section>
<section class="stats">
<article class="stat"><span>Events</span><strong id="logsTotalStat">0</strong><small id="logsVisibleStat">0 visible</small></article>
<article class="stat"><span>Streams</span><strong id="logsStreamsStat">0</strong><small>Streams in selected group</small></article>
<article class="stat"><span>Latest</span><strong id="logsNewestStat" class="small">No events</strong><small id="logsUpdatedStat">Not refreshed yet</small></article>
<article class="stat"><span>Fetch</span><strong id="logsFetchStat" class="small">Idle</strong><small id="logsFetchDetail">Endpoint: ${escapeHtml(CLOUDWATCH_ENDPOINT)} (${escapeHtml(CLOUDWATCH_REGION)})</small></article>
</section>
<div id="logsContentPane" class="contentPane">
<div class="contentStack">
<div id="logsBanner" class="banner" hidden></div>
<div id="logsEmpty" class="empty" hidden></div>
<section id="logsList" class="logList" aria-live="polite"></section>
<div class="paneTopWrap">
<button id="logsScrollToTopButton" class="paneTopButton" type="button" title="Scroll to top" aria-label="Scroll to top">&#8593;</button>
</div>
</div>
</div>
</section>
<section id="secretsPanel" class="workspacePanel" hidden>
<section class="toolControls">
<div class="row">
<button id="secretsRefreshButton" class="primary" type="button">🔄 Refresh</button>
<label class="chip"><input id="secretsAutoToggle" type="checkbox" checked> Live refresh</label>
<label class="chip">Every
<select id="secretsIntervalSelect">
<option value="5000">5s</option>
<option value="10000" selected>10s</option>
<option value="15000">15s</option>
<option value="30000">30s</option>
<option value="60000">60s</option>
</select>
</label>
<span id="secretsStatusChip" class="status">Waiting for first refresh...</span>
</div>
<div class="row">
<input id="secretsSearchInput" class="search" type="search" placeholder="Search secret name, description, service, tags..." autocomplete="off">
<button id="secretsClearSearchButton" class="ghost" type="button">Clear</button>
<button id="secretsExpandAllButton" class="ghost" type="button">Open all</button>
<button id="secretsCollapseAllButton" class="ghost" type="button">Close all</button>
</div>
</section>
<section class="stats">
<article class="stat"><span>Secrets</span><strong id="secretsTotalStat">0</strong><small id="secretsVisibleStat">0 visible</small></article>
<article class="stat"><span>Loaded</span><strong id="secretsLoadedStat">0</strong><small>Values loaded this session</small></article>
<article class="stat"><span>Latest</span><strong id="secretsNewestStat" class="small">No secrets</strong><small id="secretsUpdatedStat">Not refreshed yet</small></article>
<article class="stat"><span>Fetch</span><strong id="secretsFetchStat" class="small">Idle</strong><small id="secretsFetchDetail">Endpoint: ${escapeHtml(SECRETS_ENDPOINT)} (${escapeHtml(SECRETS_REGION)})</small></article>
</section>
<div id="secretsContentPane" class="contentPane">
<div class="contentStack">
<div id="secretsBanner" class="banner" hidden></div>
<div id="secretsEmpty" class="empty" hidden></div>
<section id="secretsList" class="list" aria-live="polite"></section>
<div class="paneTopWrap">
<button id="secretsScrollToTopButton" class="paneTopButton" type="button" title="Scroll to top" aria-label="Scroll to top">&#8593;</button>
</div>
</div>
</div>
</section>
<section id="s3Panel" class="workspacePanel" hidden>
<section class="toolControls">
<div class="row">
<button id="s3RefreshButton" class="primary" type="button">🔄 Refresh</button>
<label class="chip"><input id="s3AutoToggle" type="checkbox" checked> Live refresh</label>
<label class="chip">Every
<select id="s3IntervalSelect">
<option value="5000">5s</option>
<option value="10000" selected>10s</option>
<option value="15000">15s</option>
<option value="30000">30s</option>
<option value="60000">60s</option>
</select>
</label>
<span id="s3StatusChip" class="status">Waiting for first refresh...</span>
</div>
<div class="row">
<label class="chip">Bucket
<select id="s3BucketSelect"></select>
</label>
<input id="s3PrefixInput" class="search searchCompact" type="search" placeholder="Prefix filter (optional)" autocomplete="off">
<button id="s3ApplyPrefixButton" class="ghost" type="button">Apply prefix</button>
</div>
<div class="row">
<input id="s3SearchInput" class="search" type="search" placeholder="Search object key, storage class, or etag..." autocomplete="off">
<button id="s3ClearSearchButton" class="ghost" type="button">Clear</button>
<button id="s3ExpandAllButton" class="ghost" type="button">Open all</button>
<button id="s3CollapseAllButton" class="ghost" type="button">Close all</button>
</div>
</section>
<section class="stats">
<article class="stat"><span>Objects</span><strong id="s3TotalStat">0</strong><small id="s3VisibleStat">0 visible</small></article>
<article class="stat"><span>Buckets</span><strong id="s3BucketsStat">0</strong><small>Available in LocalStack</small></article>
<article class="stat"><span>Latest</span><strong id="s3NewestStat" class="small">No objects</strong><small id="s3UpdatedStat">Not refreshed yet</small></article>
<article class="stat"><span>Fetch</span><strong id="s3FetchStat" class="small">Idle</strong><small id="s3FetchDetail">Endpoint: ${escapeHtml(S3_ENDPOINT)} (${escapeHtml(S3_REGION)})</small></article>
</section>
<div id="s3ContentPane" class="contentPane">
<div class="contentStack">
<div id="s3Banner" class="banner" hidden></div>
<div id="s3Empty" class="empty" hidden></div>
<section id="s3List" class="list" aria-live="polite"></section>
<div class="paneTopWrap">
<button id="s3ScrollToTopButton" class="paneTopButton" type="button" title="Scroll to top" aria-label="Scroll to top">&#8593;</button>
</div>
</div>
</div>
</section>
</div>
<script type="module" src="/app.js"></script>
</body>
</html>`;
}
function renderStyles() {
return `
:root{--panel:rgba(255,255,255,.82);--panel-strong:#fff;--card-shell:linear-gradient(180deg,rgba(255,246,236,.98),rgba(255,252,247,.99));--card-body:#fffdf9;--log-shell:linear-gradient(180deg,rgba(239,246,255,.98),rgba(248,251,255,.99));--log-body:#f8fbff;--secret-shell:linear-gradient(180deg,rgba(239,251,246,.98),rgba(247,253,249,.99));--secret-body:#f6fcf8;--bucket-shell:linear-gradient(180deg,rgba(255,249,232,.98),rgba(255,252,243,.99));--bucket-body:#fffcf2;--ink:#1f2933;--muted:#607080;--line:rgba(31,41,51,.12);--card-line:rgba(207,109,60,.24);--log-line:rgba(48,113,169,.22);--secret-line:rgba(31,143,101,.2);--bucket-line:rgba(181,137,37,.22);--accent:#cf6d3c;--accent-soft:rgba(207,109,60,.1);--info:#3071a9;--info-soft:rgba(48,113,169,.1);--secret:#1f8f65;--secret-soft:rgba(31,143,101,.1);--bucket:#9d6b00;--bucket-soft:rgba(181,137,37,.12);--ok:#1f8f65;--warn:#9d5f00;--bad:#b33a3a;--shadow:0 12px 28px rgba(35,43,53,.08);--card-shadow:0 18px 34px rgba(122,78,34,.12);--log-shadow:0 16px 32px rgba(48,113,169,.12);--secret-shadow:0 16px 32px rgba(31,143,101,.12);--bucket-shadow:0 16px 32px rgba(181,137,37,.12);}
*{box-sizing:border-box}
html,body{margin:0;height:100%;overflow:hidden}
body{color-scheme:light;background:radial-gradient(circle at top left,rgba(207,109,60,.18),transparent 28%),radial-gradient(circle at top right,rgba(31,143,101,.12),transparent 24%),linear-gradient(180deg,#f8f5ef,#efe7da);color:var(--ink);font:15px/1.45 "Aptos","Segoe UI Variable Display","Segoe UI",system-ui,sans-serif;transition:background-color .18s ease,color .18s ease}
button,input,select,textarea{font:inherit}
button{cursor:pointer}
.page{display:grid;grid-template-rows:auto minmax(0,1fr);gap:10px;max-width:1360px;height:100vh;height:100dvh;margin:0 auto;padding:14px;overflow:hidden}
.hero{display:block;margin-bottom:0}
.heroShell,.toolControls,.stat{background:var(--panel);backdrop-filter:blur(14px);border:1px solid var(--line);box-shadow:var(--shadow)}
.card{background:var(--card-shell);border:1px solid var(--card-line);box-shadow:var(--card-shadow)}
.heroShell,.toolControls{border-radius:18px}
.heroShell{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px}
.toolControls{padding:12px}
.heroIdentity{display:grid;gap:3px;min-width:0}
.eyebrow{margin:0 0 4px;color:var(--accent);font-size:.72rem;font-weight:700;letter-spacing:.16em;text-transform:uppercase}
h1{margin:0;font-size:clamp(1.8rem,3.6vw,2.85rem);line-height:.96;letter-spacing:-.05em}
.lede{margin:8px 0 0;max-width:54ch;color:var(--muted);font-size:.92rem}
.heroTopRow{display:flex;flex:1 1 360px;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end}
.heroActions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end}
.heroStatusRow{display:flex;flex:1 1 100%;flex-wrap:wrap;gap:8px;align-items:center}
.heroStatusLabel{color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase}
.helper{margin:0;color:var(--muted);font-size:.89rem}
.healthStrip{display:flex;flex:1 1 520px;flex-wrap:wrap;gap:6px;align-items:center;min-width:0}
.healthBadge{display:inline-flex;align-items:center;gap:8px;min-height:30px;max-width:100%;padding:0 10px;border-radius:999px;border:1px solid rgba(31,41,51,.1);background:rgba(255,255,255,.78);box-shadow:0 8px 18px rgba(15,23,42,.06);text-align:left;transition:transform .12s ease,background-color .12s ease,border-color .12s ease,box-shadow .12s ease}
.healthBadgeName{display:inline-flex;align-items:center;gap:6px;font-size:.8rem;font-weight:800;white-space:nowrap}
.healthBadgeSummary{min-width:0;overflow:hidden;color:var(--muted);font-size:.78rem;font-weight:700;text-overflow:ellipsis;white-space:nowrap}
.healthBadge.ok{border-color:rgba(31,143,101,.22);background:rgba(31,143,101,.1)}
.healthBadge.bad{border-color:rgba(179,58,58,.22);background:rgba(179,58,58,.1)}
.healthBadge.warn{border-color:rgba(157,95,0,.22);background:rgba(157,95,0,.1)}
.healthBadge.active{border-color:rgba(207,109,60,.28);background:rgba(207,109,60,.16);box-shadow:0 10px 24px rgba(207,109,60,.12)}
.healthBadge.active .healthBadgeName,.healthBadge.active .healthBadgeSummary{color:var(--ink)}
.healthRefreshButton{flex:0 0 auto;padding:0 10px}
.primary,.ghost,.mini,.tab{display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:999px;border:1px solid transparent;transition:transform .12s ease,background-color .12s ease,border-color .12s ease}
.themeToggle{white-space:nowrap}
.workspacePanel{display:grid;grid-template-rows:auto auto minmax(0,1fr);gap:6px;min-height:0}
.workspacePanel[hidden]{display:none}
.toolControls{display:grid;gap:8px}
.contentPane{height:100%;min-height:0;overflow:auto;scroll-behavior:smooth;padding-right:4px}
.contentStack{display:grid;gap:8px;min-width:100%;padding-bottom:18px}
.paneTopWrap{display:flex;justify-content:flex-end;position:sticky;bottom:14px;pointer-events:none;padding-right:10px}
.paneTopButton{display:inline-flex;align-items:center;justify-content:center;width:42px;height:42px;border-radius:999px;border:1px solid rgba(255,255,255,.32);background:rgba(31,41,51,.42);color:#fff;font-size:1.1rem;line-height:1;backdrop-filter:blur(12px);box-shadow:0 10px 24px rgba(31,41,51,.18);opacity:0;transform:translateY(8px);visibility:hidden;pointer-events:none;transition:opacity .16s ease,transform .16s ease,background-color .12s ease;z-index:6}
.paneTopButton.visible{opacity:.78;transform:translateY(0);visibility:visible;pointer-events:auto}
.paneTopButton.visible:hover{opacity:1;background:rgba(31,41,51,.62);transform:translateY(-1px)}
.row{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
.primary,.ghost{min-height:34px;padding:0 12px;font-weight:700}
.mini,.tab{min-height:28px;padding:0 10px;font-weight:600}
.primary{background:var(--accent);color:#fff7f2}
.ghost,.mini{background:rgba(255,255,255,.76);border-color:var(--line);color:var(--ink)}
.tab{background:transparent;color:var(--muted)}
.tab.active{background:#fff;border-color:rgba(207,109,60,.18);color:var(--ink)}
.primary:hover,.ghost:hover,.mini:hover,.tab:hover{transform:translateY(-1px)}
.chip{display:inline-flex;align-items:center;gap:7px;min-height:34px;padding:0 10px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);font-weight:600;font-size:.88rem}
.chip input{margin:0;accent-color:var(--accent)}
.chip select{border:none;background:transparent;outline:none;color:var(--ink)}
.search{flex:1 1 260px;min-height:36px;padding:0 12px;border-radius:12px;border:1px solid var(--line);background:rgba(255,255,255,.82);color:var(--ink);outline:none}
.searchCompact{flex:1 1 220px}
.status{display:inline-flex;align-items:center;min-height:32px;padding:0 11px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);color:var(--muted);font-size:.86rem;font-weight:600}
.status.ok{color:var(--ok);border-color:rgba(31,143,101,.22);background:rgba(31,143,101,.08)}
.status.warn{color:var(--warn);border-color:rgba(157,95,0,.22);background:rgba(157,95,0,.08)}
.status.bad{color:var(--bad);border-color:rgba(179,58,58,.22);background:rgba(179,58,58,.08)}
.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:0}
.stat{border-radius:16px;padding:10px 12px}
.stat span{display:block;margin-bottom:4px;color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
.stat strong{display:block;font-size:clamp(1.6rem,3vw,2rem);line-height:1;letter-spacing:-.05em}
.stat strong.small{font-size:1.1rem;line-height:1.3;letter-spacing:-.02em}
.stat small{display:block;margin-top:4px;color:var(--muted);font-size:.82rem}
.banner,.empty{margin:0;padding:12px 14px;border-radius:14px;border:1px solid var(--line);background:rgba(255,255,255,.82)}
.banner{color:var(--bad);border-color:rgba(179,58,58,.24);background:rgba(179,58,58,.08)}
.list{display:grid;gap:12px;align-content:start}
.logList{display:grid;gap:10px;align-content:start;width:100%}
.card{overflow:hidden;border-radius:16px}
.card.new{border-color:rgba(31,143,101,.3);box-shadow:var(--card-shadow),0 0 0 1px rgba(31,143,101,.12)}
.summary{list-style:none;display:grid;gap:7px;padding:12px 14px;cursor:pointer;background:linear-gradient(180deg,rgba(255,250,244,.88),rgba(255,246,238,.96))}
.summary::-webkit-details-marker{display:none}
.top,.tags,.toolbar,.actions,.attachments{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.top{justify-content:space-between}
.head{min-width:0;flex:1 1 320px}
.head h2{margin:0;font-size:clamp(1rem,1.6vw,1.22rem);line-height:1.18;letter-spacing:-.03em;word-break:break-word}
.meta{margin:4px 0 0;color:var(--muted);font-size:.88rem;word-break:break-word}
.time,.tag{display:inline-flex;align-items:center;min-height:24px;padding:0 10px;border-radius:999px;font-size:.76rem;font-weight:700}
.time{background:rgba(31,41,51,.06)}
.tag{background:var(--accent-soft);color:#8d5632}
.tag.new{background:rgba(31,143,101,.1);color:var(--ok)}
.tag.bad{background:rgba(179,58,58,.1);color:var(--bad)}
.preview{margin:0;color:#324150;font-size:.9rem}
.body{display:grid;gap:10px;padding:10px 14px 14px;border-top:1px solid rgba(207,109,60,.14);background:var(--card-body)}
.toolbar{justify-content:space-between;align-items:center}
.tabs{display:inline-flex;gap:4px;padding:3px;border-radius:999px;background:rgba(207,109,60,.08)}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px}
.metaCard{padding:9px 11px;border-radius:12px;background:rgba(255,255,255,.78);border:1px solid rgba(207,109,60,.12)}
.metaCard dt{margin:0 0 4px;color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
.metaCard dd{margin:0;word-break:break-word}
.attachments{gap:6px}
.attachment{display:inline-flex;align-items:center;gap:8px;padding:7px 10px;border-radius:10px;background:rgba(255,248,240,.96);border:1px solid rgba(207,109,60,.12);font-size:.84rem}
.attachmentLink{color:#8d5632;text-decoration:none;transition:transform .12s ease,background-color .12s ease,border-color .12s ease}
.attachmentLink:hover{transform:translateY(-1px);background:#fff;border-color:rgba(207,109,60,.28)}
.panel{overflow:hidden;border-radius:12px;border:1px solid rgba(207,109,60,.14);background:#fff}
.logEvent{width:100%;overflow:hidden;border-radius:16px;border:1px solid var(--log-line);background:var(--log-shell);box-shadow:var(--log-shadow)}
.secretCard{background:var(--secret-shell);border:1px solid var(--secret-line);box-shadow:var(--secret-shadow)}
.s3Card{background:var(--bucket-shell);border:1px solid var(--bucket-line);box-shadow:var(--bucket-shadow)}
.logSummary{list-style:none;display:grid;gap:7px;padding:10px 12px;cursor:pointer}
.logSummary::-webkit-details-marker{display:none}
.secretSummary{background:linear-gradient(180deg,rgba(244,253,248,.9),rgba(236,249,242,.96))}
.s3Summary{background:linear-gradient(180deg,rgba(255,251,238,.92),rgba(255,246,223,.98))}
.logSummaryTop{display:flex;flex-wrap:wrap;gap:8px;justify-content:space-between;align-items:center}
.logMeta{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.logSummaryActions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end}
.logTag{background:var(--info-soft);color:var(--info);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.secretTag{background:var(--secret-soft);color:var(--secret);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.bucketTag{background:var(--bucket-soft);color:var(--bucket);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.logPreview{margin:0;color:#324150;font:600 .88rem/1.45 "Cascadia Code","Consolas",monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.logBody{padding:8px 12px 12px;border-top:1px solid rgba(48,113,169,.14);background:var(--log-body)}
.secretBody{border-top-color:rgba(31,143,101,.14);background:var(--secret-body)}
.s3Body{border-top-color:rgba(181,137,37,.18);background:var(--bucket-body)}
.logCopyButton{box-shadow:none}
.logBody pre{border-radius:12px;border:1px solid rgba(48,113,169,.14);padding:12px;background:linear-gradient(180deg,rgba(48,113,169,.04),transparent 140px),#fff}
.secretValuePanel{display:grid;gap:10px}
.secretValuePanel pre{border-radius:12px;border:1px solid rgba(31,143,101,.14);padding:12px;background:linear-gradient(180deg,rgba(31,143,101,.04),transparent 140px),#fff}
.s3PreviewPanel{display:grid;gap:10px}
.s3PreviewImage{max-width:min(100%,640px);border-radius:12px;border:1px solid rgba(181,137,37,.16);background:#fff}
.logBody.wrapOff pre{white-space:pre;word-break:normal}
.tag.levelError{background:rgba(179,58,58,.12);color:var(--bad)}
.tag.levelWarn{background:rgba(157,95,0,.12);color:var(--warn)}
.tag.levelInfo{background:rgba(48,113,169,.12);color:var(--info)}
.tag.levelDebug{background:rgba(96,112,128,.12);color:var(--muted)}
.jsonSyntax .jsonKey{color:#b55f2d}
.jsonSyntax .jsonString{color:#1f8f65}
.jsonSyntax .jsonNumber{color:#2f6ea9}
.jsonSyntax .jsonBoolean{color:#9d5f00}
.jsonSyntax .jsonNull{color:#b33a3a}
iframe{width:100%;min-height:560px;border:none;background:#fff}
pre{margin:0;padding:12px;white-space:pre-wrap;word-break:break-word;overflow:auto;font:12.5px/1.45 "Cascadia Code","Consolas",monospace;color:#102030;background:linear-gradient(180deg,rgba(207,109,60,.04),transparent 140px),#fff}
.placeholder,.inlineError{padding:12px}
.inlineError{color:var(--bad)}
body[data-theme="dark"]{color-scheme:dark;background:radial-gradient(circle at top left,rgba(207,109,60,.12),transparent 28%),radial-gradient(circle at top right,rgba(48,113,169,.12),transparent 26%),linear-gradient(180deg,#10161d,#17202a)}
body[data-theme="dark"] .heroShell,
body[data-theme="dark"] .toolControls,
body[data-theme="dark"] .stat{background:rgba(15,21,30,.84);border-color:rgba(148,163,184,.16);box-shadow:0 14px 30px rgba(0,0,0,.32)}
body[data-theme="dark"] .card{background:linear-gradient(180deg,rgba(50,35,28,.96),rgba(31,25,22,.98));border-color:rgba(207,109,60,.24);box-shadow:0 18px 34px rgba(0,0,0,.34)}
body[data-theme="dark"] .logEvent{background:linear-gradient(180deg,rgba(18,31,45,.96),rgba(14,24,36,.98));border-color:rgba(73,144,204,.22);box-shadow:0 16px 32px rgba(0,0,0,.34)}
body[data-theme="dark"] .secretCard{background:linear-gradient(180deg,rgba(19,39,31,.96),rgba(14,30,24,.98));border-color:rgba(64,170,126,.22);box-shadow:0 16px 32px rgba(0,0,0,.34)}
body[data-theme="dark"] .s3Card{background:linear-gradient(180deg,rgba(52,42,17,.96),rgba(37,30,13,.98));border-color:rgba(181,137,37,.24);box-shadow:0 16px 32px rgba(0,0,0,.34)}
body[data-theme="dark"] .healthBadge{background:rgba(15,21,30,.84);border-color:rgba(148,163,184,.18);box-shadow:0 10px 22px rgba(0,0,0,.28)}
body[data-theme="dark"] .healthBadge.active{border-color:rgba(207,109,60,.32);background:rgba(207,109,60,.18);box-shadow:0 12px 26px rgba(0,0,0,.3)}
body[data-theme="dark"] .healthBadge.active .healthBadgeName,
body[data-theme="dark"] .healthBadge.active .healthBadgeSummary{color:#f8ede6}
body[data-theme="dark"] .tab{color:#aab8c8}
body[data-theme="dark"] .tab.active,
body[data-theme="dark"] .ghost,
body[data-theme="dark"] .mini,
body[data-theme="dark"] .chip,
body[data-theme="dark"] .status,
body[data-theme="dark"] .search{background:rgba(18,25,35,.88);border-color:rgba(148,163,184,.18);color:#edf2f7}
body[data-theme="dark"] .chip select,
body[data-theme="dark"] .search::placeholder{color:#9fb0c2}
body[data-theme="dark"] .ghost,
body[data-theme="dark"] .mini,
body[data-theme="dark"] .tab.active{border-color:rgba(148,163,184,.18)}
body[data-theme="dark"] .summary{background:linear-gradient(180deg,rgba(58,40,31,.88),rgba(45,33,28,.96))}
body[data-theme="dark"] .body{background:#211a17;border-top-color:rgba(207,109,60,.18)}
body[data-theme="dark"] .logSummary{background:linear-gradient(180deg,rgba(21,34,47,.94),rgba(16,27,39,.98))}
body[data-theme="dark"] .logBody{background:#13212d;border-top-color:rgba(73,144,204,.18)}
body[data-theme="dark"] .secretSummary{background:linear-gradient(180deg,rgba(21,43,34,.94),rgba(16,34,27,.98))}
body[data-theme="dark"] .secretBody{background:#12241c;border-top-color:rgba(64,170,126,.18)}
body[data-theme="dark"] .s3Summary{background:linear-gradient(180deg,rgba(53,41,19,.94),rgba(39,31,15,.98))}
body[data-theme="dark"] .s3Body{background:#241d10;border-top-color:rgba(181,137,37,.18)}
body[data-theme="dark"] .metaCard{background:rgba(17,25,35,.64);border-color:rgba(148,163,184,.14)}
body[data-theme="dark"] .attachment{background:rgba(50,35,28,.9);border-color:rgba(207,109,60,.18)}
body[data-theme="dark"] .attachmentLink{color:#f6c4a9}
body[data-theme="dark"] .attachmentLink:hover{background:rgba(75,52,39,.96);border-color:rgba(246,196,169,.26)}
body[data-theme="dark"] .panel,
body[data-theme="dark"] pre,
body[data-theme="dark"] .logBody pre{background:linear-gradient(180deg,rgba(73,144,204,.06),transparent 140px),#0f1722;color:#e8edf3;border-color:rgba(148,163,184,.16)}
body[data-theme="dark"] .secretValuePanel pre{background:linear-gradient(180deg,rgba(64,170,126,.08),transparent 140px),#0f1722;color:#e8edf3;border-color:rgba(148,163,184,.16)}
body[data-theme="dark"] .s3PreviewPanel pre{background:linear-gradient(180deg,rgba(181,137,37,.08),transparent 140px),#0f1722;color:#e8edf3;border-color:rgba(148,163,184,.16)}
body[data-theme="dark"] .panel{border-color:rgba(148,163,184,.14)}
body[data-theme="dark"] .banner,
body[data-theme="dark"] .empty{background:rgba(15,21,30,.82);border-color:rgba(148,163,184,.16)}
body[data-theme="dark"] .time{background:rgba(148,163,184,.12);color:#e8edf3}
body[data-theme="dark"] .tag{background:rgba(207,109,60,.14);color:#f0c2aa}
body[data-theme="dark"] .logTag{background:rgba(73,144,204,.16);color:#93cfff}
body[data-theme="dark"] .secretTag{background:rgba(64,170,126,.16);color:#9fe0be}
body[data-theme="dark"] .bucketTag{background:rgba(181,137,37,.16);color:#f1d38c}
body[data-theme="dark"] .preview,
body[data-theme="dark"] .logPreview,
body[data-theme="dark"] .metaCard dd,
body[data-theme="dark"] .head h2,
body[data-theme="dark"] .stat strong,
body[data-theme="dark"] h1{color:#edf2f7}
body[data-theme="dark"] .jsonSyntax .jsonKey{color:#f0b08a}
body[data-theme="dark"] .jsonSyntax .jsonString{color:#80d5b0}
body[data-theme="dark"] .jsonSyntax .jsonNumber{color:#94c9ff}
body[data-theme="dark"] .jsonSyntax .jsonBoolean{color:#f0c274}
body[data-theme="dark"] .jsonSyntax .jsonNull{color:#ff9c9c}
body[data-theme="dark"] .meta,
body[data-theme="dark"] .helper,
body[data-theme="dark"] .lede,
body[data-theme="dark"] .stat small,
body[data-theme="dark"] .stat span,
body[data-theme="dark"] .chip,
body[data-theme="dark"] .tab{color:#aab8c8}
body[data-theme="dark"] .paneTopButton{border-color:rgba(255,255,255,.18);background:rgba(8,12,18,.58);color:#edf2f7}
body[data-theme="dark"] .paneTopButton.visible:hover{background:rgba(8,12,18,.8)}
@media (max-width:1080px){.stats{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media (max-width:720px){.page{padding:12px}.heroShell,.heroTopRow,.toolbar,.row,.heroActions{align-items:stretch}.heroTopRow{justify-content:stretch;flex-basis:100%}.heroStatusRow{align-items:flex-start}.heroStatusLabel,.healthStrip{flex-basis:100%}.primary,.ghost,.chip,.themeToggle{width:100%;justify-content:center}.healthBadge{justify-content:flex-start}.logSummaryTop,.logSummaryActions{align-items:flex-start}.contentPane{min-height:300px}iframe{min-height:420px}}
`;
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export { getClientConfig, renderHtml };

View File

@@ -0,0 +1,424 @@
# Commission-Based Cut Feature Manual Test Plan
## Purpose
Use this guide to manually test the commission-based cut feature from an end-user point of view.
This plan is written for a non-technical tester. Follow the steps exactly as written and mark each scenario as Pass or Fail.
## What You Need Before You Start
- A login that can open `Manage my Shop`, `Jobs`, and `Time Tickets`.
- At least 2 active employees in the shop.
- At least 1 converted repair order that already has labor lines on it.
- If possible, use a simple test job where the labor sale rates are easy to calculate.
- A notebook, spreadsheet, or screenshot folder to record what happened.
## Recommended Easy-Math Test Data
If you can choose your own test job, use something simple like this:
- Body sale rate: `$100.00`
- Refinish sale rate: `$120.00`
- Mechanical sale rate: `$80.00`
- 1 Body labor line with `10.0` hours
- 1 Refinish labor line with `4.0` hours
This makes the expected payout easy to check:
- `40%` of `$100.00` = `$40.00`
- `30%` of `$120.00` = `$36.00`
## Important Navigation Notes
- Team setup is under `Manage my Shop` > `Employee Teams`.
- Team assignment happens on the job line grid in the `Team` column.
- Automatic payout happens from the job's `Labor Allocations` card using the `Pay All` button.
- If your shop uses task presets, the `Flag Hours` button can preview the payout method before committing tickets.
---
## Scenario 1: Create a Simple Commission Team
### Goal
Confirm a team member can be set to commission and saved successfully.
### Steps
1. Sign in.
2. Click `Manage my Shop`.
3. Click the `Employee Teams` tab.
4. Click `New Team`.
5. In `Team Name`, type `Commission Team Test`.
6. Make sure `Active` is turned on.
7. In `Max Load`, enter `10`.
8. Click `New Team Member`.
9. In `Employee`, choose an active employee.
10. In `Allocation %`, enter `100`.
11. In `Payout Method`, choose `Commission %`.
12. In each commission field that appears, enter a value.
13. For the main labor types you plan to test, use these values:
14. Enter `40` for Body.
15. Enter `30` for Refinish.
16. Enter `25` for Mechanical.
17. Enter `20` for Frame.
18. Enter `15` for Glass.
19. Fill in the remaining commission boxes with any valid number from `0` to `100`.
20. Click `Save`.
### Expected Result
- The team saves successfully.
- The team stays visible in the Employee Teams list.
- The team member card shows a `Commission` tag.
- The `Allocation Total` shows `100%`.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 2: Allocation Total Must Equal 100%
### Goal
Confirm the system blocks a team that does not total exactly 100%.
### Steps
1. Stay on the same team.
2. Change `Allocation %` from `100` to `90`.
3. Click `Save`.
4. Change `Allocation %` from `90` to `110`.
5. Click `Save`.
6. Change `Allocation %` back to `100`.
7. Click `Save` again.
### Expected Result
- When the total is `90%`, the system should not save.
- When the total is `110%`, the system should not save.
- The page should show that the allocation total is not correct.
- When the total is set back to `100%`, the save should succeed.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 3: The Same Employee Cannot Be Added Twice
### Goal
Confirm the same employee cannot appear twice on one team.
### Steps
1. Open the same team again.
2. Click `New Team Member`.
3. Choose the same employee already used on the team.
4. Enter any valid allocation amount.
5. Choose `Commission %`.
6. Fill in all required commission fields.
7. Click `Save`.
### Expected Result
- The system should block the save.
- The team should not save with the same employee listed twice.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 4: Switching Between Hourly and Commission Changes the Input Style
### Goal
Confirm the rate section changes correctly when the payout method changes.
### Steps
1. Open the same team again.
2. On the team member row, change `Payout Method` from `Commission %` to `Hourly`.
3. Look at the rate fields that appear.
4. Change `Payout Method` back to `Commission %`.
5. Look at the rate fields again.
### Expected Result
- In `Hourly` mode, the rate boxes should behave like money/rate fields.
- In `Commission %` mode, the rate boxes should behave like percentage fields.
- The screen should clearly show you are editing the correct type of value.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 5: Boundary Values for Commission %
### Goal
Confirm the feature accepts valid boundary values and blocks invalid ones.
### Steps
1. Open the team again.
2. In one commission box, enter `0`.
3. In another commission box, enter `100`.
4. Click `Save`.
5. Try to type a value above `100` in one of the commission boxes.
6. Try to type a negative value in one of the commission boxes.
### Expected Result
- `0` should be accepted.
- `100` should be accepted.
- Values above `100` should not be allowed or should fail validation.
- Negative values should not be allowed or should fail validation.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 6: Inactive Teams Should Not Be Offered for New Assignment
### Goal
Confirm inactive teams do not appear as normal team choices.
### Steps
1. Open the team again.
2. Turn `Active` off.
3. Click `Save`.
4. Open a converted repair order.
5. Go to the job lines area where the `Team` column is visible.
6. Click inside the `Team` field on any labor line.
7. Open the team drop-down list.
8. Look for `Commission Team Test`.
9. Go back to `Manage my Shop` > `Employee Teams`.
10. Turn `Active` back on.
11. Click `Save`.
12. Return to the same job line and open the `Team` drop-down again.
### Expected Result
- When the team is inactive, it should not appear as a normal assignment choice.
- After turning it back on, it should appear again.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 7: Assign the Commission Team to a Labor Line
### Goal
Confirm the team can be assigned to a job line from the job screen.
### Steps
1. Open a converted repair order that has labor lines.
2. Find a labor line in the job line grid.
3. In the `Team` column, click the blank area or the current team name.
4. From the drop-down list, choose `Commission Team Test`.
5. Click outside the field so it saves.
6. Repeat for at least 1 Body line and 1 Refinish line if both exist.
### Expected Result
- The selected team name should appear in the `Team` column.
- The assignment should stay in place after the screen refreshes.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 8: Pay All Creates Commission-Based Tickets
### Goal
Confirm `Pay All` creates time tickets using the commission rate, not a flat hourly rate.
### Steps
1. Use a converted repair order that has:
2. At least 1 labor line assigned to `Commission Team Test`.
3. Known labor sale rates on the job.
4. No existing time tickets for the same employee and labor type.
5. Open that repair order.
6. Go to the labor/payroll area where the `Labor Allocations` card is visible.
7. Write down the following before you click anything:
8. The labor type on the line.
9. The sold labor rate for that labor type.
10. The hours on that line.
11. The commission % you entered for that labor type on the team.
12. Click `Pay All`.
13. Wait for the success message.
14. Look at the `Time Tickets` list on the same screen.
15. Find the new ticket created for that employee.
### Expected Result
- The system should show `All hours paid out successfully.`
- A new time ticket should appear.
- The ticket rate should equal:
- `sale rate x commission %`
- Example: if Body sale rate is `$100.00` and commission is `40%`, the ticket rate should be `$40.00`.
- The productive hours should match the assigned labor hours for that employee.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 9: Different Labor Types Use Different Commission Rates
### Goal
Confirm the feature uses the correct commission % for each labor type.
### Steps
1. Use a job that has at least:
2. One Body labor line.
3. One Refinish labor line.
4. Make sure both lines are assigned to `Commission Team Test`.
5. Confirm your team is set up like this:
6. Body = `40%`
7. Refinish = `30%`
8. Open the job's `Labor Allocations` area.
9. Click `Pay All`.
10. Review the new time tickets that are created.
### Expected Result
- The Body ticket should use the Body commission %.
- The Refinish ticket should use the Refinish commission %.
- Example:
- If Body sale rate is `$100.00`, Body payout rate should be `$40.00`.
- If Refinish sale rate is `$120.00`, Refinish payout rate should be `$36.00`.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 10: Mixed Team With Commission and Hourly Members
### Goal
Confirm one team can contain both commission and hourly members, and each person is paid correctly.
### Steps
1. Open `Manage my Shop` > `Employee Teams`.
2. Open `Commission Team Test`.
3. Edit the first team member:
4. Keep Employee 1 as `Commission %`.
5. Change `Allocation %` to `60`.
6. Make sure Body commission is still `40`.
7. Add a second team member.
8. Choose a different active employee.
9. Set `Allocation %` to `40`.
10. Set `Payout Method` to `Hourly`.
11. Enter an hourly rate for each required labor type.
12. For Body, use `$25.00`.
13. Fill in the other required hourly boxes with valid values.
14. Make sure the total allocation shows `100%`.
15. Click `Save`.
16. Assign this team to a Body line with `10.0` hours.
17. Click `Pay All`.
18. Review the new time tickets.
### Expected Result
- Employee 1 should receive `60%` of the hours at the commission-derived rate.
- Employee 2 should receive `40%` of the hours at the hourly rate you entered.
- Example with a 10-hour Body line and `$100.00` sale rate:
- Employee 1 should get `6.0` hours at `$40.00`.
- Employee 2 should get `4.0` hours at `$25.00`.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 11: Pay All Only Adds the Remaining Hours
### Goal
Confirm `Pay All` does not duplicate hours that were already paid.
### Steps
1. Use a job with one Body line assigned to `Commission Team Test`.
2. Make sure the line has `10.0` hours.
3. In the `Time Tickets` card, click `Enter New Time Ticket`.
4. Create a manual time ticket for the same employee and the same labor type.
5. Enter `4.0` productive hours.
6. Save the manual time ticket.
7. Go back to the `Labor Allocations` card.
8. Click `Pay All`.
9. Review the new ticket that is created.
### Expected Result
- The system should only create the remaining unpaid hours.
- In this example, it should add `6.0` hours, not `10.0`.
- The payout rate should still use the current commission-based rate.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 12: Unassigned Labor Lines Should Block Automatic Payout
### Goal
Confirm `Pay All` does not silently pay lines that do not have a team assigned.
### Steps
1. Open a converted repair order with at least 2 labor lines.
2. Assign `Commission Team Test` to one line.
3. Leave the second labor line with no team assigned.
4. Go to the `Labor Allocations` card.
5. Click `Pay All`.
### Expected Result
- The system should not quietly pay everything.
- You should see an error telling you that not all hours have been assigned.
- The unassigned line should still need manual attention.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Scenario 13: Flag Hours Preview Shows the Correct Payout Method
### Goal
If your shop uses task presets, confirm the preview shows `Commission` for commission-based tickets.
### Steps
1. Open a converted repair order.
2. Go to the `Time Tickets` card.
3. Click `Flag Hours`.
4. Choose a task preset.
5. Wait for the preview table to load.
6. Review the `Payout Method` column in the preview.
7. If the preview includes more than one employee, review each row.
### Expected Result
- The preview table should load without error.
- Rows for commission-based employees should show `Commission`.
- Rows for hourly employees should show `Hourly`.
- If there are unassigned hours, a warning should appear.
### Record
- [ ] Pass
- [ ] Fail
- Notes:
---
## Quick Regression Checklist
- [ ] I can create a commission-based team.
- [ ] Allocation must total exactly 100%.
- [ ] The same employee cannot be added twice to one team.
- [ ] Inactive teams do not appear for normal assignment.
- [ ] A team can be assigned to job lines from the `Team` column.
- [ ] `Pay All` creates commission-based tickets correctly.
- [ ] Different labor types use different commission percentages.
- [ ] Mixed commission and hourly teams calculate correctly.
- [ ] `Pay All` only creates the remaining unpaid hours.
- [ ] Unassigned labor lines stop automatic payout.
- [ ] `Flag Hours` preview shows the correct payout method.
## Tester Sign-Off
- Tester name:
- Test date:
- Environment:
- Overall result:
- Follow-up issues found:

View File

@@ -0,0 +1,647 @@
# Ant Design Select.Option Deprecation - Manual Testing Plan
**Branch:** `feature/IO-3544-Ant-Select-Deprecation`
**Base Branch:** `master-AIO`
**Jira:** IO-3544
## Overview
This branch migrates all Ant Design `<Select.Option>` components to the new `options` prop pattern (required for Ant Design v5+). The deprecated `Select.Option` child component pattern has been replaced with the `options` array prop.
## What Changed
- **Old Pattern:** `<Select><Select.Option value="x">Label</Select.Option></Select>`
- **New Pattern:** `<Select options={[{ value: "x", label: "Label" }]} />`
- Search filtering updated from `optionFilterProp: "children"` to `optionFilterProp: "label"` or custom props
- Custom filter functions updated to use `option.label` instead of `option.props.children`
- Complex components with custom rendered content (tags, icons, styled elements) now use `label` prop with JSX
## Code Review Findings ✅
**Validation completed on representative samples:**
- ✅ Search functionality properly migrated (showSearch object syntax correct)
- ✅ Custom rendered content (tags, badges, icons) preserved in label prop
- ✅ Labor type selectors correctly implement all 14 types
- ✅ Vendor search with favorites and discount tags working correctly
- ✅ Employee selectors with flat_rate/straight_time tags properly structured
- ✅ Job search with owner display and status tags correctly migrated
- ✅ CiecaSelect utility updated to return options array format
- ✅ Performance optimizations added (useMemo in jobs-convert-button)
- ✅ Custom optionFilterProp values used where needed (e.g., "name", "search")
## Testing Strategy
For each select component below, verify:
1.**Options Display:** All options appear correctly
2.**Selection:** Can select an option and value is saved
3.**Search/Filter:** Search functionality works (if applicable)
4.**Visual Rendering:** Labels, tags, and custom content display properly (especially vendor discounts, employee tags, job status badges)
5.**Form Integration:** Value persists and submits correctly
6.**Custom Search Props:** Components using custom optionFilterProp (name, search) work correctly
---
## Component Test Cases
### 1. Employee Assignment & Allocation
**Files Modified:**
- `allocations-assignment.component.jsx`
- `allocations-bulk-assignment.component.jsx`
- `labor-allocations-adjustment-edit.component.jsx`
- `employee-search-select.component.jsx`
- `employee-search-select-email.component.jsx`
**Test Scenarios:**
- [ ] **Job Line Allocation:** Assign employee to job line
- Navigate to a job → Job Lines tab
- Click allocate hours to an employee
- Verify employee dropdown shows full names
- Search for employee by name
- Verify selection saves
- [ ] **Bulk Assignment:** Assign multiple job lines to employee
- Select multiple job lines
- Open bulk assignment modal
- Verify employee selector works
- Verify employee number, name, and tags (flat rate/straight time) display
- [ ] **Labor Allocations Adjustment:** Edit labor allocations
- Navigate to labor allocations
- Edit adjustment form
- Verify employee dropdowns work with search
- [ ] **Employee Search Select with Email:**
- Test in any form using employee email selector (uses custom `optionFilterProp: "search"`)
- Search by employee number, first name, or last name
- Verify employee number, name display correctly
- **Critical:** Verify green tag shows "Flat Rate" or "Straight Time"
- Verify blue email tag displays when showEmail=true
- Test that search matches against concatenated string
---
### 2. Job Management
**Files Modified:**
- `job-search-select.component.jsx`
- `jobs-create-jobs-info.component.jsx`
- `jobs-detail-general.component.jsx`
- `jobs-detail-header-actions.component.jsx`
- `jobs-convert-button.component.jsx`
- `jobs-close-lines.component.jsx`
- `job-3rd-party-modal.component.jsx`
**Test Scenarios:**
- [ ] **Job Search Select:** Search and select jobs
- Any form with job selector (e.g., linking jobs, referencing jobs)
- Search by RO number, customer name, or vehicle (uses `filterOption: false` with custom search)
- **Critical:** Verify job label shows: `[CLM_NO |] RO_NUMBER | Owner Name | Year Make Model`
- **Critical:** Verify status tag displays (e.g., "OPEN", "CLOSED")
- Verify loading spinner appears during search
- Test with claim numbers visible (clm_no prop)
- Verify selection works
- [ ] **Create Job Form:**
- Navigate to Create New Job
- Test all dropdowns in job info section:
- Job type selection
- Status selection
- Department/class selection
- Estimator selection
- File handler selection
- Verify options display and selection works
- [ ] **Job Detail General Tab:**
- Open any job → General tab
- Test select dropdowns:
- Status select
- Department/class select
- Estimator select
- File handler select
- Responsibility center select
- Verify all dropdowns work with search
- [ ] **Job Convert Button:**
- Open estimate job
- Click convert to RO
- Verify conversion type dropdown works
- Test all options in conversion modal
- [ ] **Close Job Lines:**
- Open converted job
- Go to close/finalize
- Test location selector in close lines modal
- Verify cost center dropdowns
- [ ] **Third Party Modal:**
- Open job → Third party integration
- Test company/payer selector
- Verify dropdown options and selection
---
### 3. Job Lines & Labor
**Files Modified:**
- `job-lines-upsert-modal.component.jsx`
- `job-line-bulk-assign.component.jsx`
- `job-line-convert-to-labor.component.jsx`
- `job-line-dispatch-button.component.jsx`
- `job-line-status-popup.component.jsx`
- `job-line-team-assignment.component.jsx`
**Test Scenarios:**
- [ ] **Add/Edit Job Line:**
- Open job → Add new line
- Test all dropdowns:
- **Labor type selector** (LAA, LAB, LAD, LAE, LAF, LAG, LAM, LAR, LAS, LAU, LA1-LA4)
- Location selector
- Status selector
- Skill/category selector
- Verify 14 labor type options display correctly
- Test search functionality
- [ ] **Bulk Line Assignment:**
- Select multiple job lines
- Open bulk assign
- Test team assignment dropdown
- [ ] **Convert to Labor:**
- Select part line
- Convert to labor
- Test labor type dropdown (LAA, LAB, etc.)
- Verify all 14 types available
- [ ] **Line Dispatch:**
- Open dispatch modal for job line
- Test team/employee selector
- [ ] **Line Status Popup:**
- Change job line status
- Verify status dropdown options
- [ ] **Team Assignment:**
- Assign team to job line
- Test team selector dropdown
---
### 4. Owners & Vehicles
**Files Modified:**
- `owner-search-select.component.jsx`
- `vehicle-search-select.component.jsx`
**Test Scenarios:**
- [ ] **Owner Search Select:**
- Any form with owner selector
- Search by owner name
- Verify owner options display
- Test selection and search
- [ ] **Vehicle Search Select:**
- Any form with vehicle selector
- Search by VIN, license plate, or vehicle description
- Verify vehicle options display correctly
- Test selection works
---
### 5. Vendors & Parts
**Files Modified:**
- `vendor-search-select.component.jsx`
- `parts-order-modal.component.jsx`
- `parts-receive-modal.component.jsx`
**Test Scenarios:**
- [ ] **Vendor Search Select:**
- Navigate to bills or parts ordering
- Test vendor selector
- Search for vendor by name (uses custom `optionFilterProp: "name"`)
- **Critical:** Verify favorites (with heart icon) display at top
- **Critical:** Verify discount tags show correctly (e.g., "10%")
- **Critical:** Verify vendor tags display
- Verify phone numbers display if showPhone enabled
- Test selection saves discount value to form
- [ ] **Parts Order Modal:**
- Order parts for a job
- Test all dropdowns in order form:
- Vendor selector
- Status selector
- Priority selector
- Verify options and selection
- [ ] **Parts Receive Modal:**
- Receive parts
- Test selectors in receive form
- Verify dropdown functionality
---
### 6. Bills & Payments
**Files Modified:**
- `bill-form.component.jsx`
- `bill-form-lines.component.jsx`
- `bill-form-lines-extended.formitem.component.jsx`
- `payment-form.component.jsx`
**Test Scenarios:**
- [ ] **Bill Entry Form:**
- Navigate to Bills → Add New Bill
- Test all dropdowns:
- Vendor selector
- Payment terms selector
- GL account selector
- Tax code selector
- Verify options display
- [ ] **Bill Lines:**
- Add bill line
- Test line-level selectors:
- Job selector
- Job line selector
- Account selector
- Location selector
- [ ] **Bill Lines Extended:**
- Add extended bill line
- Test responsibility center dropdown
- Test cost center dropdown
- [ ] **Payment Form:**
- Navigate to Payments → New Payment
- Test all dropdowns:
- Vendor selector
- Payment method selector
- Bank account selector
- Verify selection works
---
### 7. Shop Configuration
**Files Modified:**
- `shop-info.general.component.jsx`
- `shop-info.intake.component.jsx`
- `shop-info.responsibilitycenters.component.jsx`
- `shop-info.rostatus.component.jsx`
- `shop-info.speedprint.component.jsx`
- `shop-intellipay-config.component.jsx`
- `shop-employees-form.component.jsx`
**Test Scenarios:**
- [ ] **Shop Info - General:**
- Navigate to Shop Settings → General
- Test all dropdowns:
- Timezone selector
- Currency selector
- Date format selector
- Default options
- [ ] **Shop Info - Intake:**
- Navigate to Shop Settings → Intake
- Test intake form selectors
- Verify default options work
- [ ] **Shop Info - Responsibility Centers:**
- Navigate to Shop Settings → Responsibility Centers
- Test cost center dropdowns
- Test location selectors
- **Note:** This file had major changes (980 lines modified)
- [ ] **Shop Info - RO Status:**
- Navigate to Shop Settings → RO Status
- Test status configuration dropdowns
- **Note:** 120 lines modified
- [ ] **Shop Info - Speed Print:**
- Navigate to Shop Settings → Speed Print
- Test printer selector
- Test template selector
- [ ] **IntelliPay Config:**
- Navigate to Shop Settings → IntelliPay
- Test configuration dropdowns (56 lines modified)
- [ ] **Shop Employees Form:**
- Navigate to Shop Settings → Employees → Add/Edit
- Test all dropdowns:
- Role selector
- Department selector
- Pay type selector
- Verify options display
---
### 8. Schedule & Time Tracking
**Files Modified:**
- `schedule-job-modal.component.jsx`
- `schedule-manual-event.component.jsx`
- `tech-job-clock-in-form.component.jsx`
- `tech-job-clock-out-button.component.jsx`
- `time-ticket-modal.component.jsx`
- `time-ticket-shift-form.component.jsx`
**Test Scenarios:**
- [ ] **Schedule Job Modal:**
- Navigate to Schedule → Add Appointment
- Test all dropdowns:
- Job selector
- Employee selector
- Time slot selector
- Duration selector
- [ ] **Schedule Manual Event:**
- Add manual event to schedule
- Test event type dropdown
- Test employee selector
- [ ] **Tech Clock In Form:**
- Navigate to Tech Portal
- Clock in to job
- Test job selector
- Test operation selector
- [ ] **Tech Clock Out:**
- Clock out from job
- Test reason selector (if applicable)
- Verify dropdown works
- [ ] **Time Ticket Modal:**
- Enter/edit time ticket
- Test all dropdowns:
- Employee selector
- Job selector
- Operation selector
- [ ] **Time Ticket Shift Form:**
- Manage shift
- Test shift type selector
- Test employee selector
---
### 9. Contracts & Courtesy Cars
**Files Modified:**
- `contract-convert-to-ro.component.jsx`
- `contract-status-select.component.jsx`
- `courtesy-car-readiness-select.component.jsx`
- `courtesy-car-status-select.component.jsx`
**Test Scenarios:**
- [ ] **Contract Convert to RO:**
- Open contract
- Convert to RO
- Test conversion options dropdown
- [ ] **Contract Status Select:**
- Change contract status
- Test status options:
- New
- Out
- Returned
- Verify all 3 status options work
- [ ] **Courtesy Car Readiness:**
- Navigate to Courtesy Cars
- Change car readiness
- Test readiness options:
- Ready
- Not Ready
- Verify both options work
- [ ] **Courtesy Car Status:**
- Change courtesy car status
- Test all status options:
- In
- In Service
- Out
- Sold
- Lease Return
- Unavailable
- Verify all 6 status options work
---
### 10. Email & Communication
**Files Modified:**
- `email-overlay.component.jsx`
- `chat-tag-ro.component.jsx`
- `parts-shop-info-email-presets.component.jsx`
**Test Scenarios:**
- [ ] **Email Overlay:**
- Send email from any feature
- Test all dropdowns:
- From email selector (current user, shop email, custom emails)
- Template selector
- Priority selector
- Verify custom from emails display correctly
- [ ] **Chat Tag RO:**
- Open chat
- Tag to RO
- Test RO selector dropdown
- [ ] **Parts Shop Email Presets:**
- Navigate to Parts Settings → Email Presets
- Test preset selector
- Verify options display
---
### 11. DMS Integration
**Files Modified:**
- `dms-post-form/cdklike-dms-post-form.jsx`
- `dms/dms.container.jsx`
- `dms-payables/dms-payables.container.jsx`
**Test Scenarios:**
- [ ] **DMS Post Form:**
- Navigate to DMS posting
- Test all dropdowns in post form:
- Account selector
- Department selector
- GL code selector
- [ ] **DMS Container:**
- Navigate to DMS section
- Test filter dropdowns
- Verify selection works
- [ ] **DMS Payables:**
- Navigate to DMS Payables
- Test payables filter selectors
---
### 12. Production & Admin
**Files Modified:**
- `production-list-config-manager.component.jsx`
- `jobs-admin-class.component.jsx`
- `jobs-close/jobs-close.component.jsx`
**Test Scenarios:**
- [ ] **Production List Config:**
- Navigate to Production Board → Configure
- Test column configuration dropdown
- Verify display settings work
- [ ] **Jobs Admin Class:**
- Navigate to Admin → Jobs
- Change job class/department
- Test class selector dropdown
- [ ] **Jobs Close Page:**
- Navigate to Jobs → Close/Export
- Test filter dropdowns:
- Status filter
- Date range
- Department filter
- Verify selections work
---
### 13. Miscellaneous Components
**Files Modified:**
- `Ciecaselect.jsx` (utility component - 75 lines modified)
**Test Scenarios:**
- [ ] **CIECA Select Utility:**
- Used in bill-form-lines-extended for labor type adjustments
- Returns options array with 14 labor types (LAA-LAU, LA1-LA4)
- Returns 10 part types (PAA, PAC, PAL, PAG, PAM, PAP, PAN, PAO, PAR, PAS) when parts=true
- Verify function returns properly formatted options array
- Test in any form using DMS integration with CIECA codes
---
## Cross-Component Testing
### Search Functionality
Test search across all searchable selects:
- [ ] Employee search (by name, employee number)
- [ ] Job search (by RO number, customer name, vehicle)
- [ ] Vendor search (by name)
- [ ] Vehicle search (by VIN, plate, make/model)
- [ ] Owner search (by name)
### Multi-Select Components
If any components use `mode="multiple"`:
- [ ] Verify multi-select works
- [ ] Verify tags display correctly
- [ ] Verify removal of selections works
### Disabled State
- [ ] Test dropdowns in disabled state
- [ ] Verify disabled styling matches original
### Form Validation
- [ ] Test required field validation on selects
- [ ] Verify error messages display correctly
- [ ] Test form submission with select values
---
## Regression Testing Priority
### High Priority (Critical User Flows)
1. ✅ Create new job with all required fields
2. ✅ Add job lines with labor types
3. ✅ Assign employees to job lines
4. ✅ Order parts with vendor selection
5. ✅ Enter bills with vendor and account selection
6. ✅ Close and export jobs
### Medium Priority
7. Convert estimate to RO
8. Schedule appointments
9. Clock in/out (tech portal)
10. Update shop configuration
11. Manage courtesy cars
### Low Priority (Admin Functions)
12. DMS integration posting
13. Production board configuration
14. Admin job modifications
---
## Browser Testing
Test in:
- [ ] Chrome (latest)
- [ ] Firefox (latest)
- [ ] Safari (if applicable)
- [ ] Edge (latest)
---
## Known Changed Components Summary
**Total Files Modified:** 54 client files + 1 server file
**Labor Type Selectors (14 options):**
- LAA, LAB, LAD, LAE, LAF, LAG, LAM, LAR, LAS, LAU, LA1, LA2, LA3, LA4
- Found in: job-lines-upsert-modal, job-line-convert-to-labor, bill-form-lines
**Most Complex Changes:**
- `shop-info.responsibilitycenters.component.jsx` (980 lines changed)
- `vendor-search-select.component.jsx` (120 lines changed)
- `shop-info.rostatus.component.jsx` (120 lines changed)
- `jobs-convert-button.component.jsx` (198 lines changed)
- `Ciecaselect.jsx` (75 lines changed)
---
## Internal Code Review Results ✅
**Files Validated (Sample):**
1.`allocations-assignment.component.jsx` - Simple employee selector with search
2.`contract-status-select.component.jsx` - Static 3-option select
3.`courtesy-car-status-select.component.jsx` - Static 6-option select
4.`job-lines-upsert-modal.component.jsx` - 14 labor type options inline
5.`email-overlay.component.jsx` - From email with custom emails array
6.`employee-search-select-email.component.jsx` - Complex with tags and custom search prop
7.`bill-form-lines-extended.formitem.component.jsx` - CiecaSelect utility usage
8.`vendor-search-select.component.jsx` - Complex with favorites, tags, discount, phone
9.`job-search-select.component.jsx` - Complex with owner display, status tags, loading states
10.`Ciecaselect.jsx` - Utility function returning options array
**Validation Checklist:**
- [x] All `Select.Option` patterns removed
- [x] Replaced with `options` array prop
- [x] `showSearch` uses object syntax `{{ optionFilterProp: "label" }}`
- [x] Custom `optionFilterProp` used where needed ("name", "search", etc.)
- [x] Complex rendered content preserved in `label` prop with JSX
- [x] Tags, icons, badges, and styled elements working correctly
- [x] Search functionality using correct property references
- [x] Labor types: All 14 types present (LAA, LAB, LAD, LAE, LAF, LAG, LAM, LAR, LAS, LAU, LA1, LA2, LA3, LA4)
- [x] Part types: All 10 types in CiecaSelect (PAA, PAC, PAL, PAG, PAM, PAP, PAN, PAO, PAR, PAS)
- [x] Custom filterOption functions updated (option.label vs option.props.children)
- [x] Performance optimizations added (useMemo for large option lists)
- [x] labelRender custom rendering preserved where used
- [x] optionLabelProp used correctly for display vs value
**Known Complex Patterns Verified:**
1. **Vendor Select:** Favorites with heart icon, discount tags, phone display, custom search by "name"
2. **Employee Select:** Flat rate/straight time tags, custom search by "search" prop (employee number + name)
3. **Job Select:** Owner display function, status tags, loading states, conditional claim number display
4. **Email Overlay:** Multiple from addresses (user email, shop email, custom md_from_emails array)
5. **Bill Lines Extended:** Conditional DMS vs responsibility centers, CiecaSelect utility
**No Issues Found** - All migrations follow the correct pattern.
---
## Testing Notes
- Focus on components with search functionality - filtering logic changed from `children` to `label`
- Pay attention to components with custom rendered content (tags, badges, formatted text)
- Verify `optionFilterProp` works correctly for custom search fields
- Test components that map over arrays to generate options
- Check components with conditional option rendering
---
## Sign-Off
- [ ] All high priority tests passed
- [ ] All medium priority tests passed
- [ ] All low priority tests passed
- [ ] No console errors observed
- [ ] Visual appearance matches original
- [ ] Performance is acceptable (no lag in large dropdowns)
**Tested By:** _________________
**Date:** _________________
**Environment:** _________________
**Notes:** _________________

File diff suppressed because it is too large Load Diff

View File

@@ -18,3 +18,4 @@ VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com
VITE_APP_AMP_KEY=6228a598e57cd66875cfd41604f1f891
VITE_APP_ENABLE_RESPONSIVE_TABLE_FILTERING=false

View File

@@ -20,3 +20,4 @@ VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com
VITE_APP_AMP_KEY=46b1193a867d4e3131ae4c3a64a3fc78
VITE_APP_ENABLE_RESPONSIVE_TABLE_FILTERING=false

1698
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,76 +8,77 @@
"private": true,
"proxy": "http://localhost:4000",
"dependencies": {
"@amplitude/analytics-browser": "^2.34.0",
"@amplitude/analytics-browser": "^2.38.0",
"@ant-design/pro-layout": "^7.22.6",
"@apollo/client": "^4.1.3",
"@apollo/client": "^4.1.6",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@documenso/embed-react": "^0.5.1",
"@emotion/is-prop-valid": "^1.4.0",
"@fingerprintjs/fingerprintjs": "^5.0.1",
"@firebase/analytics": "^0.10.19",
"@firebase/app": "^0.14.7",
"@firebase/auth": "^1.12.0",
"@firebase/firestore": "^4.10.0",
"@firebase/messaging": "^0.12.22",
"@fingerprintjs/fingerprintjs": "^5.1.0",
"@firebase/analytics": "^0.10.21",
"@firebase/app": "^0.14.10",
"@firebase/auth": "^1.12.2",
"@firebase/firestore": "^4.13.0",
"@firebase/messaging": "^0.12.25",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.11.2",
"@sentry/cli": "^3.1.0",
"@sentry/react": "^10.38.0",
"@sentry/vite-plugin": "^4.8.0",
"@sentry/cli": "^3.3.5",
"@sentry/react": "^10.47.0",
"@sentry/vite-plugin": "^4.9.1",
"@splitsoftware/splitio-react": "^2.6.1",
"@tanem/react-nprogress": "^5.0.58",
"antd": "^6.2.2",
"@tanem/react-nprogress": "^5.0.63",
"antd": "^6.3.5",
"apollo-link-logger": "^3.0.0",
"autosize": "^6.0.1",
"axios": "^1.13.4",
"axios": "^1.14.0",
"classnames": "^2.5.1",
"css-box-model": "^1.2.1",
"dayjs": "^1.11.19",
"dayjs-business-days2": "^1.3.2",
"dayjs": "^1.11.20",
"dayjs-business-days2": "^1.3.3",
"dinero.js": "^1.9.1",
"dotenv": "^17.2.3",
"dotenv": "^17.3.1",
"env-cmd": "^11.0.0",
"exifr": "^7.1.3",
"graphql": "^16.12.0",
"graphql-ws": "^6.0.7",
"i18next": "^25.8.0",
"i18next-browser-languagedetector": "^8.2.0",
"graphql": "^16.13.2",
"graphql-ws": "^6.0.8",
"i18next": "^25.10.10",
"i18next-browser-languagedetector": "^8.2.1",
"immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.36",
"lightningcss": "^1.31.1",
"logrocket": "^12.0.0",
"libphonenumber-js": "^1.12.41",
"lightningcss": "^1.32.0",
"logrocket": "^12.1.0",
"markerjs2": "^2.32.7",
"memoize-one": "^6.0.0",
"normalize-url": "^8.1.1",
"object-hash": "^3.0.0",
"phone": "^3.1.70",
"posthog-js": "^1.336.4",
"phone": "^3.1.71",
"posthog-js": "^1.364.4",
"prop-types": "^15.8.1",
"query-string": "^9.3.1",
"raf-schd": "^4.0.3",
"react": "^19.2.4",
"react-big-calendar": "^1.19.4",
"react-color": "^2.19.3",
"react-cookie": "^8.0.1",
"react-cookie": "^8.1.0",
"react-dom": "^19.2.4",
"react-grid-gallery": "^1.0.1",
"react-grid-layout": "^2.2.2",
"react-i18next": "^16.5.4",
"react-icons": "^5.5.0",
"react-grid-layout": "^2.2.3",
"react-i18next": "^16.6.6",
"react-icons": "^5.6.0",
"react-image-lightbox": "^5.1.4",
"react-markdown": "^10.1.0",
"react-number-format": "^5.4.3",
"react-number-format": "^5.4.5",
"react-popopo": "^2.1.9",
"react-product-fruits": "^2.2.62",
"react-redux": "^9.2.0",
"react-resizable": "^3.1.3",
"react-router-dom": "^7.13.0",
"react-router-dom": "^7.13.2",
"react-sticky": "^6.0.3",
"react-virtuoso": "^4.18.1",
"recharts": "^3.7.0",
"react-virtuoso": "^4.18.3",
"recharts": "^3.8.1",
"redux": "^5.0.1",
"redux-actions": "^3.0.3",
"redux-persist": "^6.0.0",
@@ -85,11 +86,11 @@
"redux-state-sync": "^3.1.4",
"reselect": "^5.1.1",
"rxjs": "^7.8.2",
"sass": "^1.97.3",
"sass": "^1.98.0",
"socket.io-client": "^4.8.3",
"styled-components": "^6.3.8",
"styled-components": "^6.3.12",
"vite-plugin-ejs": "^1.7.0",
"web-vitals": "^5.1.0"
"web-vitals": "^5.2.0"
},
"scripts": {
"postinstall": "echo 'when updating react-big-calendar, remember to check to localizer in the calendar wrapper'",
@@ -137,40 +138,40 @@
"@rollup/rollup-linux-x64-gnu": "4.6.1"
},
"devDependencies": {
"@ant-design/icons": "^6.1.0",
"@ant-design/icons": "^6.1.1",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.28.5",
"@dotenvx/dotenvx": "^1.52.0",
"@dotenvx/dotenvx": "^1.59.1",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.39.2",
"@playwright/test": "^1.58.0",
"@playwright/test": "^1.58.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@vitejs/plugin-react": "^5.1.2",
"@vitejs/plugin-react": "^5.1.4",
"babel-plugin-react-compiler": "^1.0.0",
"browserslist": "^4.28.1",
"browserslist": "^4.28.2",
"browserslist-to-esbuild": "^2.1.1",
"chalk": "^5.6.2",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"globals": "^17.2.0",
"jsdom": "^27.4.0",
"memfs": "^4.56.10",
"globals": "^17.4.0",
"jsdom": "^28.1.0",
"memfs": "^4.57.1",
"os-browserify": "^0.3.0",
"playwright": "^1.58.0",
"playwright": "^1.58.2",
"react-error-overlay": "^6.1.0",
"redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.3",
"vite": "^7.3.1",
"vite-plugin-babel": "^1.4.1",
"vite-plugin-babel": "^1.6.0",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-node-polyfills": "^0.25.0",
"vite-plugin-node-polyfills": "^0.26.0",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-style-import": "^2.0.0",
"vitest": "^4.0.18",
"vitest": "^4.1.2",
"workbox-window": "^7.4.0"
}
}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { useSplitClient } from "@splitsoftware/splitio-react";
import { Button, Result } from "antd";
import LogRocket from "logrocket";
//import LogRocket from "logrocket";
import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
@@ -101,13 +101,13 @@ export function App({
client.setAttribute("imexshopid", bodyshop.imexshopid);
if (client.getTreatment("LogRocket_Tracking") === "on") {
console.log("LR Start");
LogRocket.init(
InstanceRenderMgr({
imex: "gvfvfw/bodyshopapp",
rome: "rome-online/rome-online"
})
);
// console.log("LR Start");
// LogRocket.init(
// InstanceRenderMgr({
// imex: "gvfvfw/bodyshopapp",
// rome: "rome-online/rome-online"
// })
// );
}
}
}, [bodyshop, client, currentUser.authorized]);

View File

@@ -443,35 +443,76 @@
flex-direction: column;
}
/* DMS top panels: prevent card/table overflow into adjacent column at desktop+zoom */
.dms-top-panel-col {
min-width: 0;
}
.dms-top-panel-col > .ant-card {
width: 100%;
min-width: 0;
max-width: 100%;
}
.dms-top-panel-col > .ant-card .ant-card-body {
min-width: 0;
max-width: 100%;
}
.dms-top-panel-col .ant-table-wrapper,
.dms-top-panel-col .ant-tabs,
.dms-top-panel-col .ant-tabs-content,
.dms-top-panel-col .ant-tabs-tabpane {
min-width: 0;
max-width: 100%;
}
//.rbc-time-header-gutter {
// padding: 0;
//}
/* globally allow shrink inside table cells */
.prod-list-table .ant-table-cell,
.prod-list-table .ant-table-cell > * {
min-width: 0;
///* globally allow shrink inside table cells */
//.prod-list-table .ant-table-cell,
//.prod-list-table .ant-table-cell > * {
// min-width: 0;
//}
//
///* common AntD offenders */
//.prod-list-table > .ant-table-cell .ant-space,
//.ant-table-cell .ant-space-item {
// min-width: 0;
//}
//
///* Keep your custom header content on the left, push AntD sorter to the far right */
//.prod-list-table .ant-table-column-sorters {
// display: flex !important;
// align-items: center;
// width: 100%;
//}
//
//.prod-list-table .ant-table-column-title {
// flex: 1 1 auto;
// min-width: 0; /* allows ellipsis to work */
//}
//
//.prod-list-table .ant-table-column-sorter {
// margin-left: auto;
// flex: 0 0 auto;
//}
.global-search-autocomplete-fix {
// This is the extra value render that causes the “duplicate text”
.ant-select-selection-item {
position: absolute !important;
left: -10000px !important;
pointer-events: none !important;
}
}
/* common AntD offenders */
.prod-list-table > .ant-table-cell .ant-space,
.ant-table-cell .ant-space-item {
min-width: 0;
}
/* Keep your custom header content on the left, push AntD sorter to the far right */
.prod-list-table .ant-table-column-sorters {
display: flex !important;
align-items: center;
width: 100%;
}
.prod-list-table .ant-table-column-title {
flex: 1 1 auto;
min-width: 0; /* allows ellipsis to work */
}
.prod-list-table .ant-table-column-sorter {
margin-left: auto;
flex: 0 0 auto;
}
.esignature-embed {
width: 100%;
height: 100%;
border-width: 0;
}

View File

@@ -68,7 +68,7 @@ const currentTheme = import.meta.env.DEV ? devTheme : prodTheme;
const getTheme = (isDarkMode) => ({
algorithm: isDarkMode ? darkAlgorithm : defaultAlgorithm,
...defaultsDeep(currentTheme, defaultTheme)
...defaultsDeep({}, currentTheme, defaultTheme(isDarkMode))
});
export default getTheme;

View File

@@ -1,7 +1,11 @@
import { Button } from "antd";
import { Button, Card, Divider, Form, Space, Typography } from "antd";
import { connect } from "react-redux";
import queryString from "query-string";
import { useLocation } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { setModalContext } from "../../redux/modals/modals.actions";
import { PayrollLaborAllocationsTable } from "../labor-allocations-table/labor-allocations-table.payroll.component.jsx";
import { TimeTicketTaskModalComponent } from "../time-ticket-task-modal/time-ticket-task-modal.component.jsx";
const mapStateToProps = createStructuredSelector({});
@@ -9,8 +13,109 @@ const mapDispatchToProps = (dispatch) => ({
setRefundPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "refund_payment" }))
});
const commissionCutFixture = {
bodyshop: {
features: {
timetickets: true
},
employees: [
{ id: "emp-1", first_name: "Avery", last_name: "Johnson" },
{ id: "emp-2", first_name: "Morgan", last_name: "Lee" }
],
md_tasks_presets: {
presets: [
{
name: "Body Prep",
percent: 50,
hourstype: ["LAA", "LAB"],
nextstatus: "In Progress"
}
]
}
},
jobId: "fixture-job-1",
joblines: [
{
id: "line-1",
mod_lbr_ty: "LAA",
mod_lb_hrs: 4,
assigned_team: "team-1",
convertedtolbr: false
}
],
previewValues: {
task: "Body Prep",
timetickets: [
{
employeeid: "emp-1",
cost_center: "Body",
ciecacode: "LAA",
productivehrs: 2,
rate: 40,
payoutamount: 80,
payout_context: {
payout_method: "commission"
}
},
{
employeeid: "emp-2",
cost_center: "Refinish",
ciecacode: "LAB",
productivehrs: 1,
rate: 28,
payoutamount: 28,
payout_context: {
payout_method: "hourly"
}
}
]
}
};
function CommissionCutHarness() {
const [form] = Form.useForm();
return (
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<Typography.Title level={2}>Commission Cut Test Harness</Typography.Title>
<Typography.Paragraph>
This fixture keeps commission-cut browser checks stable by rendering representative payroll and preview UI with
local data.
</Typography.Paragraph>
<Card title="Payroll Labor Allocations">
<PayrollLaborAllocationsTable
jobId={commissionCutFixture.jobId}
joblines={commissionCutFixture.joblines}
timetickets={[]}
bodyshop={commissionCutFixture.bodyshop}
adjustments={[]}
refetch={() => {}}
/>
</Card>
<Divider />
<Card title="Claim Task Preview">
<Form form={form} initialValues={commissionCutFixture.previewValues} layout="vertical">
<TimeTicketTaskModalComponent
bodyshop={commissionCutFixture.bodyshop}
form={form}
loading={false}
completedTasks={[]}
unassignedHours={1.25}
/>
</Form>
</Card>
</Space>
);
}
function Test({ setRefundPaymentContext, refundPaymentModal }) {
const search = queryString.parse(useLocation().search);
console.log("refundPaymentModal", refundPaymentModal);
if (search.fixture === "commission-cut") {
return <CommissionCutHarness />;
}
return (
<div>
<Button

View File

@@ -1,4 +1,4 @@
import { Card, Checkbox, Input, Space, Table } from "antd";
import { Card, Checkbox, Input, Space } from "antd";
import queryString from "query-string";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -16,6 +16,7 @@ import PayableExportAll from "../payable-export-all-button/payable-export-all-bu
import PayableExportButton from "../payable-export-button/payable-export-button.component";
import BillMarkSelectedExported from "../payable-mark-selected-exported/payable-mark-selected-exported.component";
import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import useLocalStorage from "./../../utils/useLocalStorage";
const mapStateToProps = createStructuredSelector({
@@ -179,11 +180,12 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, bills, ref
</Space>
}
>
<Table
<ResponsiveTable
loading={loading}
dataSource={dataSource}
pagination={{ placement: "top", pageSize: exportPageLimit, showSizeChanger: false }}
columns={columns}
mobileColumnKeys={["vendorname", "invoice_number", "ro_number", "total", "actions"]}
rowKey="id"
onChange={handleTableChange}
rowSelection={{

View File

@@ -1,4 +1,4 @@
import { Card, Input, Space, Table } from "antd";
import { Card, Input, Space } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
@@ -17,6 +17,7 @@ import PaymentExportButton from "../payment-export-button/payment-export-button.
import PaymentMarkSelectedExported from "../payment-mark-selected-exported/payment-mark-selected-exported.component";
import PaymentsExportAllButton from "../payments-export-all-button/payments-export-all-button.component";
import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component";
import ResponsiveTable from "../responsive-table/responsive-table.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -192,11 +193,12 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, payments,
</Space>
}
>
<Table
<ResponsiveTable
loading={loading}
dataSource={dataSource}
pagination={{ placement: "top", pageSize: exportPageLimit, showSizeChanger: false }}
columns={columns}
mobileColumnKeys={["ro_number", "date", "owner", "amount", "actions"]}
rowKey="id"
onChange={handleTableChange}
rowSelection={{

View File

@@ -1,4 +1,4 @@
import { Button, Card, Input, Space, Table } from "antd";
import { Button, Card, Input, Space } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
@@ -17,6 +17,7 @@ import JobsExportAllButton from "../jobs-export-all-button/jobs-export-all-butto
import JobMarkSelectedExported from "../jobs-mark-selected-exported/jobs-mark-selected-exported";
import OwnerNameDisplay, { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component";
import ResponsiveTable from "../responsive-table/responsive-table.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -209,11 +210,12 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
</Space>
}
>
<Table
<ResponsiveTable
loading={loading}
dataSource={dataSource}
pagination={{ placement: "top", pageSize: exportPageLimit, showSizeChanger: false }}
columns={columns}
mobileColumnKeys={["ro_number", "status", "owner", "clm_total", "actions"]}
rowKey="id"
onChange={handleTableChange}
rowSelection={{

View File

@@ -1,5 +1,5 @@
import { Alert } from "antd";
export default function AlertComponent(props) {
return <Alert {...props} />;
export default function AlertComponent({ title, message, ...props }) {
return <Alert {...props} title={title ?? message} />;
}

View File

@@ -29,19 +29,18 @@ export function AllocationsAssignmentComponent({
<Select
id="employeeSelector"
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
optionFilterProp: "label",
filterOption: (input, option) => option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
}}
style={{ width: 200 }}
placeholder="Select a person"
onChange={onChange}
>
{bodyshop.employees.map((emp) => (
<Select.Option value={emp.id} key={emp.id}>
{`${emp.first_name} ${emp.last_name}`}
</Select.Option>
))}
</Select>
options={bodyshop.employees.map((emp) => ({
value: emp.id,
key: emp.id,
label: `${emp.first_name} ${emp.last_name}`
}))}
/>
<InputNumber
defaultValue={assignment.hours}
placeholder={t("joblines.fields.mod_lb_hrs")}

View File

@@ -31,19 +31,17 @@ export default connect(
<div>
<Select
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
optionFilterProp: "label",
filterOption: (input, option) => option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
}}
style={{ width: 200 }}
placeholder="Select a person"
onChange={onChange}
>
{bodyshop.employees.map((emp) => (
<Select.Option value={emp.id} key={emp.id}>
{`${emp.first_name} ${emp.last_name}`}
</Select.Option>
))}
</Select>
options={bodyshop.employees.map((emp) => ({
value: emp.id,
label: `${emp.first_name} ${emp.last_name}`
}))}
/>
<Button type="primary" disabled={!assignment.employeeid} onClick={handleAssignment}>
Assign

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { Table } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { alphaSort } from "../../utils/sorters";
import { DateTimeFormatter } from "../../utils/DateFormatter";
import { useTranslation } from "react-i18next";
@@ -62,11 +62,12 @@ export default function AuditTrailListComponent({ loading, data }) {
};
return (
<Table
<ResponsiveTable
{...formItemLayout}
loading={loading}
pagination={{ placement: "top", defaultPageSize: pageLimit }}
columns={columns}
mobileColumnKeys={[" created", "operation", " old_val", "useremail"]}
rowKey="id"
dataSource={data}
onChange={handleTableChange}

View File

@@ -1,4 +1,4 @@
import { Table } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { DateTimeFormatter } from "../../utils/DateFormatter";
@@ -47,11 +47,12 @@ export default function EmailAuditTrailListComponent({ loading, data }) {
};
return (
<Table
<ResponsiveTable
{...formItemLayout}
loading={loading}
pagination={{ placement: "top", defaultPageSize: pageLimit }}
columns={columns}
mobileColumnKeys={[" created", "useremail"]}
rowKey="id"
dataSource={data}
onChange={handleTableChange}

View File

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

View File

@@ -13,6 +13,7 @@ import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import dayjs from "../../utils/day";
import { buildBillUpdateAuditDetails } from "../../utils/auditTrailDetails";
import AlertComponent from "../alert/alert.component";
import BillFormContainer from "../bill-form/bill-form.container";
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);
const details = buildBillUpdateAuditDetails({
originalBill: data?.bills_by_pk,
bill,
billlines
});
insertAuditTrail({
jobid: bill.jobid,
jobid: bill.jobid ?? data?.bills_by_pk?.jobid,
billid: search.billid,
operation: AuditTrailMapping.billupdated(bill.invoice_number),
operation: AuditTrailMapping.billupdated(bill.invoice_number, details),
type: "billupdated"
});

View File

@@ -28,6 +28,20 @@ export function BillDetailEditReturn({ setPartsOrderContext, data, disabled }) {
const { t } = useTranslation();
const [form] = Form.useForm();
const [open, setOpen] = useState(false);
const initialValues =
data && data.bills_by_pk
? {
...data.bills_by_pk,
billlines: (data.bills_by_pk.billlines || []).map((bl) => {
const oem = bl.oem_partno || (bl.jobline && bl.jobline.oem_partno) || "";
const alt = bl.alt_partno || (bl.jobline && bl.jobline.alt_partno) || "";
return {
...bl,
oem_partno: `${oem || ""} ${alt ? `(${alt})` : ""}`.trim()
};
})
}
: undefined;
const handleFinish = ({ billlines }) => {
const selectedLines = billlines.filter((l) => l.selected).map((l) => l.id);
@@ -74,8 +88,9 @@ export function BillDetailEditReturn({ setPartsOrderContext, data, disabled }) {
destroyOnHidden
title={t("bills.actions.return")}
onOk={() => form.submit()}
width={700}
>
<Form initialValues={data?.bills_by_pk} onFinish={handleFinish} form={form}>
<Form initialValues={initialValues} onFinish={handleFinish} form={form}>
<Form.List name={["billlines"]}>
{(fields) => {
return (
@@ -95,9 +110,10 @@ export function BillDetailEditReturn({ setPartsOrderContext, data, disabled }) {
/>
</td>
<td>{t("billlines.fields.line_desc")}</td>
<td>{t("billlines.fields.quantity")}</td>
<td>{t("billlines.fields.actual_price")}</td>
<td>{t("billlines.fields.actual_cost")}</td>
<td>{t("billlines.fields.oem_partno")}</td>
<td style={{ textAlign: "right" }}>{t("billlines.fields.quantity")}</td>
<td style={{ textAlign: "right" }}>{t("billlines.fields.actual_price")}</td>
<td style={{ textAlign: "right" }}>{t("billlines.fields.actual_cost")}</td>
</tr>
</thead>
<tbody>
@@ -127,6 +143,15 @@ export function BillDetailEditReturn({ setPartsOrderContext, data, disabled }) {
</Form.Item>
</td>
<td>
<Form.Item
// label={t("joblines.fields.oem_partno")}
key={`${index}jobline.oem_partno`}
name={[field.name, "oem_partno"]}
>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td style={{ textAlign: "right" }}>
<Form.Item
// label={t("joblines.fields.quantity")}
key={`${index}quantity`}
@@ -135,7 +160,7 @@ export function BillDetailEditReturn({ setPartsOrderContext, data, disabled }) {
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>
<td style={{ textAlign: "right" }}>
<Form.Item
// label={t("joblines.fields.actual_price")}
key={`${index}actual_price`}
@@ -144,7 +169,7 @@ export function BillDetailEditReturn({ setPartsOrderContext, data, disabled }) {
<ReadOnlyFormItemComponent type="currency" />
</Form.Item>
</td>
<td>
<td style={{ textAlign: "right" }}>
<Form.Item
// label={t("joblines.fields.actual_cost")}
key={`${index}actual_cost`}

View File

@@ -7,10 +7,8 @@ export default function BillDetailEditcontainer() {
const search = queryString.parse(useLocation().search);
const history = useNavigate();
const selectedBreakpoint = Object.entries(Grid.useBreakpoint())
.filter((screen) => !!screen[1])
.slice(-1)[0];
const screens = Grid.useBreakpoint();
const bpoints = {
xs: "100%",
sm: "100%",
@@ -19,7 +17,14 @@ export default function BillDetailEditcontainer() {
xl: "90%",
xxl: "90%"
};
const drawerPercentage = selectedBreakpoint ? bpoints[selectedBreakpoint[0]] : "100%";
let drawerPercentage = "100%";
if (screens.xxl) drawerPercentage = bpoints.xxl;
else if (screens.xl) drawerPercentage = bpoints.xl;
else if (screens.lg) drawerPercentage = bpoints.lg;
else if (screens.md) drawerPercentage = bpoints.md;
else if (screens.sm) drawerPercentage = bpoints.sm;
else if (screens.xs) drawerPercentage = bpoints.xs;
return (
<Drawer

View File

@@ -0,0 +1,206 @@
import { Button, Tag, Modal, Typography } from "antd";
import axios from "axios";
import { useState } from "react";
import { FaWandMagicSparkles } from "react-icons/fa6";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext";
import { selectBillEnterModal } from "../../redux/modals/modals.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTranslation } from "react-i18next";
const mapStateToProps = createStructuredSelector({
billEnterModal: selectBillEnterModal,
bodyshop: selectBodyshop
});
function BillEnterAiScan({
billEnterModal,
bodyshop,
pollingIntervalRef,
setPollingIntervalRef,
form,
fileInputRef,
scanLoading,
setScanLoading,
setIsAiScan,
setRawAIData
}) {
const notification = useNotification();
const { t } = useTranslation();
const [showBetaModal, setShowBetaModal] = useState(false);
const BETA_ACCEPTANCE_KEY = "ai_scan_beta_acceptance";
const handleBetaAcceptance = () => {
localStorage.setItem(BETA_ACCEPTANCE_KEY, "true");
setShowBetaModal(false);
fileInputRef.current?.click();
};
const checkBetaAcceptance = () => {
const hasAccepted = localStorage.getItem(BETA_ACCEPTANCE_KEY);
if (hasAccepted) {
fileInputRef.current?.click();
} else {
setShowBetaModal(true);
}
};
// Polling function for multipage PDF status
const pollJobStatus = async (textractJobId) => {
try {
const { data } = await axios.get(`/ai/bill-ocr/status/${textractJobId}`);
if (data.status === "COMPLETED") {
// Stop polling
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
setPollingIntervalRef(null);
}
setScanLoading(false);
setRawAIData(data.data);
// Update form with the extracted data
if (data?.data?.billForm) {
form.setFieldsValue(data.data.billForm);
await form.validateFields(["billlines"], { recursive: true });
notification.success({
title: t("bills.labels.ai.scancomplete")
});
}
} else if (data.status === "FAILED") {
// Stop polling on failure
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
setPollingIntervalRef(null);
}
setScanLoading(false);
notification.error({
title: t("bills.labels.ai.scanfailed"),
description: data.error || ""
});
}
// If status is IN_PROGRESS, continue polling
} catch (error) {
// Stop polling on error
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
setPollingIntervalRef(null);
}
setScanLoading(false);
notification.error({
title: t("bills.labels.ai.scanfailed"),
description: error.response?.data?.message || error.message || "Failed to check scan status"
});
}
};
return (
<>
<input
ref={fileInputRef}
type="file"
accept="image/*,application/pdf"
style={{ display: "none" }}
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
setScanLoading(true);
setIsAiScan(true);
const formdata = new FormData();
formdata.append("billScan", file);
formdata.append("jobid", form.getFieldValue("jobid") || billEnterModal.context.job?.id);
formdata.append("bodyshopid", bodyshop.id);
formdata.append("partsorderid", billEnterModal.context.parts_order?.id);
try {
const { data, status } = await axios.post("/ai/bill-ocr", formdata);
// Add the scanned file to the upload field
const currentUploads = form.getFieldValue("upload") || [];
form.setFieldValue("upload", [
...currentUploads,
{
uid: `ai-scan-${Date.now()}`,
name: file.name,
originFileObj: file,
status: "done"
}
]);
if (status === 202) {
// Multipage PDF - start polling
notification.info({
title: t("bills.labels.ai.scanstarted"),
description: t("bills.labels.ai.multipage")
});
//Workaround needed to bypass react-compiler error about manipulating refs in child components. Refactor may be needed in the future to clean this up.
setPollingIntervalRef(
setInterval(() => {
pollJobStatus(data.textractJobId);
}, 3000)
);
// Initial poll
pollJobStatus(data.textractJobId);
} else if (status === 200) {
// Single page - immediate response
setScanLoading(false);
form.setFieldsValue(data.data.billForm);
setRawAIData(data.data);
await form.validateFields(["billlines"], { recursive: true });
notification.success({
title: t("bills.labels.ai.scancomplete")
});
}
} catch (error) {
setScanLoading(false);
notification.error({
title: t("bills.labels.ai.scanfailed"),
description: error.response?.data?.message || error.message || t("bills.labels.ai.generic_failure")
});
}
}
// Reset the input so the same file can be selected again
e.target.value = "";
}}
/>
<Button onClick={checkBetaAcceptance} icon={<FaWandMagicSparkles />} loading={scanLoading} disabled={scanLoading}>
{scanLoading ? t("bills.labels.ai.processing") : t("bills.labels.ai.scan")}
<Tag color="red">{t("general.labels.beta")}</Tag>
</Button>
<Modal
title={t("bills.labels.ai.disclaimer_title")}
open={showBetaModal}
onOk={handleBetaAcceptance}
onCancel={() => setShowBetaModal(false)}
okText={t("bills.labels.ai.accept_and_continue")}
cancelText={t("general.actions.cancel")}
>
{
//This is explicitly not translated.
}
<Typography.Text>
This AI scanning feature is currently in <strong>beta</strong>. While it can accelerate data entry, you{" "}
<strong>must carefully review all extracted results</strong> for accuracy.
</Typography.Text>
<Typography.Text>The AI may make mistakes or miss information. Always verify:</Typography.Text>
<ul>
<li>All line items and quantities</li>
<li>Prices and totals</li>
<li>Part numbers and descriptions</li>
<li>Any other critical invoice details</li>
</ul>
<Typography.Text>
By continuing, you acknowledge that you will review and verify all AI-generated data before posting.
</Typography.Text>
</Modal>
</>
);
}
export default connect(mapStateToProps, null)(BillEnterAiScan);

View File

@@ -1,11 +1,12 @@
import { useApolloClient, useMutation } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Checkbox, Form, Modal, Space } from "antd";
import { Button, Checkbox, Divider, Form, Modal, Space } from "antd";
import _ from "lodash";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { INSERT_NEW_BILL } from "../../graphql/bills.queries";
import { UPDATE_INVENTORY_LINES } from "../../graphql/inventory.queries";
@@ -21,12 +22,13 @@ import { GenerateDocument } from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants";
import confirmDialog from "../../utils/asyncConfirm";
import useLocalStorage from "../../utils/useLocalStorage";
import BillEnterAiScan from "../bill-enter-ai-scan/bill-enter-ai-scan.component.jsx";
import BillFormContainer from "../bill-form/bill-form.container";
import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility";
import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility";
import { handleUpload } from "../documents-upload/documents-upload.utility";
import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility";
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
import { handleUpload } from "../documents-upload/documents-upload.utility";
import BillAiFeedback from "../bill-ai-feedback/bill-ai-feedback.component.jsx";
const mapStateToProps = createStructuredSelector({
billEnterModal: selectBillEnterModal,
@@ -50,15 +52,21 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
const [updatePartsOrderLines] = useMutation(MUTATION_MARK_RETURN_RECEIVED);
const [updateInventoryLines] = useMutation(UPDATE_INVENTORY_LINES);
const [loading, setLoading] = useState(false);
const [scanLoading, setScanLoading] = useState(false);
const [isAiScan, setIsAiScan] = useState(false);
const [rawAIData, setRawAIData] = useState(null);
const client = useApolloClient();
const [generateLabel, setGenerateLabel] = useLocalStorage("enter_bill_generate_label", false);
const notification = useNotification();
const fileInputRef = useRef(null);
const pollingIntervalRef = useRef(null);
const formTopRef = useRef(null);
const {
treatments: { Enhanced_Payroll, Imgproxy }
treatments: { Enhanced_Payroll, Imgproxy, Bill_OCR_AI }
} = useTreatmentsWithConfig({
attributes: {},
names: ["Enhanced_Payroll", "Imgproxy"],
names: ["Enhanced_Payroll", "Imgproxy", "Bill_OCR_AI"],
splitKey: bodyshop.imexshopid
});
@@ -113,6 +121,8 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
create_ppc,
// eslint-disable-next-line no-unused-vars
original_actual_price,
// eslint-disable-next-line no-unused-vars
confidence,
...restI
} = i;
@@ -378,6 +388,8 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
vendorid: values.vendorid,
billlines: []
});
setIsAiScan(false);
setRawAIData(null);
// form.resetFields();
} else {
toggleModalVisible();
@@ -388,10 +400,23 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
const handleCancel = () => {
const r = window.confirm(t("general.labels.cancel"));
if (r === true) {
// Clean up polling on cancel
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
setScanLoading(false);
setIsAiScan(false);
setRawAIData(null);
toggleModalVisible();
}
};
//Workaround needed to bypass react-compiler error about manipulating refs in child components. Refactor may be needed in the future to clean this up.
const setPollingIntervalRef = (func) => {
pollingIntervalRef.current = func;
};
useEffect(() => {
if (enterAgain) form.submit();
}, [enterAgain, form]);
@@ -401,12 +426,46 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
form.setFieldsValue(formValues);
} else {
form.resetFields();
// Clean up polling on modal close
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
setScanLoading(false);
setIsAiScan(false);
setRawAIData(null);
}
}, [billEnterModal.open, form, formValues]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, []);
return (
<Modal
title={t("bills.labels.new")}
title={
<Space size="large">
{t("bills.labels.new")}
{Bill_OCR_AI.treatment === "on" && (
<BillEnterAiScan
fileInputRef={fileInputRef}
form={form}
pollingIntervalRef={pollingIntervalRef}
setPollingIntervalRef={setPollingIntervalRef}
scanLoading={scanLoading}
setScanLoading={setScanLoading}
setIsAiScan={setIsAiScan}
setRawAIData={setRawAIData}
/>
)}
</Space>
}
width={"98%"}
open={billEnterModal.open}
okText={t("general.actions.save")}
@@ -418,26 +477,34 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
setLoading(false);
}}
footer={
<Space>
<Checkbox checked={generateLabel} onChange={(e) => setGenerateLabel(e.target.checked)}>
{t("bills.labels.generatepartslabel")}
</Checkbox>
<Button onClick={handleCancel}>{t("general.actions.cancel")}</Button>
<Button loading={loading} onClick={() => form.submit()} id="save-bill-enter-modal">
{t("general.actions.save")}
</Button>
{billEnterModal.context && billEnterModal.context.id ? null : (
<Button
type="primary"
loading={loading}
onClick={() => {
setEnterAgain(true);
}}
id="save-and-new-bill-enter-modal"
>
{t("general.actions.saveandnew")}
</Button>
<Space orientation="vertical">
{isAiScan && (
<>
<BillAiFeedback billForm={form} rawAIData={rawAIData} />
<Divider orientation="horizontal" />
</>
)}
<Space wrap align="top">
<Checkbox checked={generateLabel} onChange={(e) => setGenerateLabel(e.target.checked)}>
{t("bills.labels.generatepartslabel")}
</Checkbox>
<Button onClick={handleCancel}>{t("general.actions.cancel")}</Button>
<Button loading={loading} onClick={() => form.submit()} id="save-bill-enter-modal">
{t("general.actions.save")}
</Button>
{billEnterModal.context && billEnterModal.context.id ? null : (
<Button
type="primary"
loading={loading}
onClick={() => {
setEnterAgain(true);
}}
id="save-and-new-bill-enter-modal"
>
{t("general.actions.saveandnew")}
</Button>
)}
</Space>
</Space>
}
destroyOnHidden
@@ -447,13 +514,25 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
autoComplete={"off"}
layout="vertical"
form={form}
onFinishFailed={() => {
onFinishFailed={(errorInfo) => {
setEnterAgain(false);
// Scroll to the top of the form to show validation errors
if (errorInfo.errorFields && errorInfo.errorFields.length > 0) {
setTimeout(() => {
formTopRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}, 100);
}
}}
>
<RbacWrapper action="bills:enter">
<BillFormContainer form={form} disableInvNumber={billEnterModal.context.disableInvNumber} />
</RbacWrapper>
<div ref={formTopRef}>
<RbacWrapper action="bills:enter">
<BillFormContainer
form={form}
isAiScan={isAiScan}
disableInvNumber={billEnterModal.context.disableInvNumber}
/>
</RbacWrapper>
</div>
</Form>
</Modal>
);

View File

@@ -1,4 +1,5 @@
import { Form, Input, Table } from "antd";
import { Form, Input } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
@@ -108,7 +109,14 @@ export default function BillFormLinesExtended({ lineData, discount, form, respon
<Form.Item noStyle name="billlineskeys">
<button onClick={() => console.log(form.getFieldsValue())}>form</button>
<Input onChange={(e) => setSearch(e.target.value)} allowClear />
<Table pagination={false} size="small" columns={columns} rowKey="id" dataSource={data} />
<ResponsiveTable
pagination={false}
size="small"
columns={columns}
mobileColumnKeys={["line_desc", "oem_partno", "part_type", "act_price"]}
rowKey="id"
dataSource={data}
/>
</Form.Item>
);
}

View File

@@ -99,20 +99,22 @@ export function BillFormItemsExtendedFormItem({
}}
</Form.Item>
<Form.Item label={t("billlines.fields.cost_center")} name={["billlineskeys", record.id, "cost_center"]}>
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled}>
{bodyshopHasDmsKey(bodyshop)
? CiecaSelect(true, false)
: responsibilityCenters.costs.map((item) => <Select.Option key={item.name}>{item.name}</Select.Option>)}
</Select>
<Select
showSearch
style={{ minWidth: "3rem" }}
disabled={disabled}
options={
bodyshopHasDmsKey(bodyshop)
? CiecaSelect(true, false)
: responsibilityCenters.costs.map((item) => ({ value: item.name, label: item.name }))
}
/>
</Form.Item>
<Form.Item label={t("billlines.fields.location")} name={["billlineskeys", record.id, "location"]}>
<Select disabled={disabled}>
{bodyshop.md_parts_locations.map((loc, idx) => (
<Select.Option key={idx} value={loc}>
{loc}
</Select.Option>
))}
</Select>
<Select
disabled={disabled}
options={bodyshop.md_parts_locations.map((loc) => ({ value: loc, label: loc }))}
/>
</Form.Item>
<Form.Item
label={t("billlines.fields.deductedfromlbr")}
@@ -136,22 +138,10 @@ export function BillFormItemsExtendedFormItem({
]}
name={["billlineskeys", record.id, "lbr_adjustment", "mod_lbr_ty"]}
>
<Select allowClear>
<Select.Option value="LAA">{t("joblines.fields.lbr_types.LAA")}</Select.Option>
<Select.Option value="LAB">{t("joblines.fields.lbr_types.LAB")}</Select.Option>
<Select.Option value="LAD">{t("joblines.fields.lbr_types.LAD")}</Select.Option>
<Select.Option value="LAE">{t("joblines.fields.lbr_types.LAE")}</Select.Option>
<Select.Option value="LAF">{t("joblines.fields.lbr_types.LAF")}</Select.Option>
<Select.Option value="LAG">{t("joblines.fields.lbr_types.LAG")}</Select.Option>
<Select.Option value="LAM">{t("joblines.fields.lbr_types.LAM")}</Select.Option>
<Select.Option value="LAR">{t("joblines.fields.lbr_types.LAR")}</Select.Option>
<Select.Option value="LAS">{t("joblines.fields.lbr_types.LAS")}</Select.Option>
<Select.Option value="LAU">{t("joblines.fields.lbr_types.LAU")}</Select.Option>
<Select.Option value="LA1">{t("joblines.fields.lbr_types.LA1")}</Select.Option>
<Select.Option value="LA2">{t("joblines.fields.lbr_types.LA2")}</Select.Option>
<Select.Option value="LA3">{t("joblines.fields.lbr_types.LA3")}</Select.Option>
<Select.Option value="LA4">{t("joblines.fields.lbr_types.LA4")}</Select.Option>
</Select>
<Select
allowClear
options={CiecaSelect(false, true)}
/>
</Form.Item>
<Form.Item
label={t("jobs.labels.adjustmentrate")}

View File

@@ -8,12 +8,15 @@ import { MdOpenInNew } from "react-icons/md";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { CHECK_BILL_INVOICE_NUMBER } from "../../graphql/bills.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
import dayjs from "../../utils/day";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import AlertComponent from "../alert/alert.component";
import BillFormLinesExtended from "../bill-form-lines-extended/bill-form-lines-extended.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import JobSearchSelect from "../job-search-select/job-search-select.component";
@@ -21,8 +24,6 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
import BillFormLines from "./bill-form.lines.component";
import { CalculateBillTotal } from "./bill-form.totals.utility";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -43,11 +44,15 @@ export function BillFormComponent({
loadOutstandingReturns,
loadInventory,
preferredMake,
disableInHouse
disableInHouse,
isAiScan
}) {
const { t } = useTranslation();
const client = useApolloClient();
const [discount, setDiscount] = useState(0);
const notification = useNotification();
const jobIdFormWatch = Form.useWatch("jobid", form);
const vendorIdFormWatch = Form.useWatch("vendorid", form);
const {
treatments: { Extended_Bill_Posting, ClosingPeriod }
@@ -114,6 +119,7 @@ export function BillFormComponent({
}
}, [
form,
vendorIdFormWatch,
billEdit,
loadOutstandingReturns,
loadInventory,
@@ -123,6 +129,23 @@ export function BillFormComponent({
bodyshop.inhousevendorid
]);
useEffect(() => {
// When the jobid is set by AI scan, we need to reload the lines. This prevents having to hoist the apollo query.
if (jobIdFormWatch !== null) {
if (form.getFieldValue("jobid") !== null && form.getFieldValue("jobid") !== undefined) {
loadLines({ variables: { id: form.getFieldValue("jobid") } });
if (form.getFieldValue("vendorid") !== null && form.getFieldValue("vendorid") !== undefined) {
loadOutstandingReturns({
variables: {
jobId: form.getFieldValue("jobid"),
vendorId: form.getFieldValue("vendorid")
}
});
}
}
}
}, [jobIdFormWatch, form]);
return (
<div>
<FormFieldsChanged form={form} />
@@ -328,13 +351,12 @@ export function BillFormComponent({
</Form.Item>
{!billEdit && (
<Form.Item label={t("bills.fields.allpartslocation")} name="location">
<Select style={{ width: "10rem" }} disabled={disabled} allowClear>
{bodyshop.md_parts_locations.map((loc, idx) => (
<Select.Option key={idx} value={loc}>
{loc}
</Select.Option>
))}
</Select>
<Select
style={{ width: "10rem" }}
disabled={disabled}
allowClear
options={bodyshop.md_parts_locations.map((loc) => ({ value: loc, label: loc }))}
/>
</Form.Item>
)}
</LayoutFormRow>
@@ -374,7 +396,15 @@ export function BillFormComponent({
]);
let totals;
if (!!values.total && !!values.billlines && values.billlines.length > 0) {
totals = CalculateBillTotal(values);
try {
totals = CalculateBillTotal(values);
} catch (error) {
notification.error({
title: t("bills.errors.calculating_totals"),
message: error.message || t("bills.errors.calculating_totals_generic"),
key: "bill_totals_calculation_error"
});
}
}
if (totals) {
@@ -452,6 +482,7 @@ export function BillFormComponent({
responsibilityCenters={responsibilityCenters}
disabled={disabled}
billEdit={billEdit}
isAiScan={isAiScan}
/>
)}
<Divider titlePlacement="left" style={{ display: billEdit ? "none" : null }}>

View File

@@ -15,7 +15,7 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
export function BillFormContainer({ bodyshop, form, billEdit, disabled, disableInvNumber, disableInHouse }) {
export function BillFormContainer({ bodyshop, form, billEdit, disabled, disableInvNumber, disableInHouse,isAiScan }) {
const {
treatments: { Simple_Inventory }
} = useTreatmentsWithConfig({
@@ -50,6 +50,7 @@ export function BillFormContainer({ bodyshop, form, billEdit, disabled, disableI
loadOutstandingReturns={loadOutstandingReturns}
loadInventory={loadInventory}
preferredMake={lineData ? lineData.jobs_by_pk.v_make_desc : null}
isAiScan={isAiScan}
/>
{!billEdit && <BillCmdReturnsTableComponent form={form} returnLoading={returnLoading} returnData={returnData} />}
{Simple_Inventory.treatment === "on" && (

View File

@@ -5,14 +5,15 @@ import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { selectDarkMode } from "../../redux/application/application.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import CiecaSelect from "../../utils/Ciecaselect";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import BillLineSearchSelect from "../bill-line-search-select/bill-line-search-select.component";
import BilllineAddInventory from "../billline-add-inventory/billline-add-inventory.component";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
import ConfidenceDisplay from "./bill-form.lines.confidence.component.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -29,7 +30,8 @@ export function BillEnterModalLinesComponent({
discount,
form,
responsibilityCenters,
billEdit
billEdit,
isAiScan
}) {
const { t } = useTranslation();
const { setFieldsValue, getFieldsValue, getFieldValue } = form;
@@ -94,6 +96,7 @@ export function BillEnterModalLinesComponent({
// Only fill actual_cost when the user forward-tabs out of Retail (actual_price)
const autofillActualCost = (index) => {
if (bodyshop.accountingconfig?.disableBillCostCalculation) return;
Promise.resolve().then(() => {
const retailRaw = form.getFieldValue(["billlines", index, "actual_price"]);
const actualRaw = form.getFieldValue(["billlines", index, "actual_cost"]);
@@ -139,6 +142,29 @@ export function BillEnterModalLinesComponent({
const columns = (remove) => {
return [
...(isAiScan
? [
{
title: t("billlines.fields.confidence"),
dataIndex: "confidence",
editable: true,
width: "5rem",
formItemProps: (field) => ({
key: `${field.index}confidence`,
name: [field.name, "confidence"],
label: t("billlines.fields.confidence")
}),
formInput: (record) => {
const rowValue = getFieldValue(["billlines", record.name]);
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<ConfidenceDisplay rowValue={rowValue} />
</div>
);
}
}
]
: []),
{
title: t("billlines.fields.jobline"),
dataIndex: "joblineid",
@@ -212,6 +238,7 @@ export function BillEnterModalLinesComponent({
}),
formInput: () => <Input.TextArea disabled={disabled} autoSize tabIndex={0} />
},
{
title: t("billlines.fields.quantity"),
dataIndex: "quantity",
@@ -250,7 +277,16 @@ export function BillEnterModalLinesComponent({
key: `${field.name}actual_price`,
name: [field.name, "actual_price"],
label: t("billlines.fields.actual_price"),
rules: [{ required: true }]
rules: [
{ required: true },
{
validator: (_, value) => {
return Math.abs(parseFloat(value)) < 0.01 ? Promise.reject() : Promise.resolve();
},
warningOnly: true
}
],
hasFeedback: true
}),
formInput: (record, index) => (
<CurrencyInput
@@ -399,11 +435,17 @@ export function BillEnterModalLinesComponent({
rules: [{ required: true }]
}),
formInput: () => (
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled} tabIndex={0}>
{bodyshopHasDmsKey(bodyshop)
? CiecaSelect(true, false)
: responsibilityCenters.costs.map((item) => <Select.Option key={item.name}>{item.name}</Select.Option>)}
</Select>
<Select
showSearch
style={{ minWidth: "3rem" }}
disabled={disabled}
tabIndex={0}
options={
bodyshopHasDmsKey(bodyshop)
? CiecaSelect(true, false)
: responsibilityCenters.costs.map((item) => ({ value: item.name, label: item.name }))
}
/>
)
},
...(billEdit
@@ -419,13 +461,11 @@ export function BillEnterModalLinesComponent({
name: [field.name, "location"]
}),
formInput: () => (
<Select disabled={disabled} tabIndex={0}>
{bodyshop.md_parts_locations.map((loc, idx) => (
<Select.Option key={idx} value={loc}>
{loc}
</Select.Option>
))}
</Select>
<Select
disabled={disabled}
tabIndex={0}
options={bodyshop.md_parts_locations.map((loc) => ({ value: loc, label: loc }))}
/>
)
}
]),
@@ -455,7 +495,9 @@ export function BillEnterModalLinesComponent({
{Enhanced_Payroll.treatment === "on" ? (
<Space>
{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>
) : null}
@@ -466,22 +508,7 @@ export function BillEnterModalLinesComponent({
rules={[{ required: true }]}
name={[record.name, "lbr_adjustment", "mod_lbr_ty"]}
>
<Select allowClear>
<Select.Option value="LAA">{t("joblines.fields.lbr_types.LAA")}</Select.Option>
<Select.Option value="LAB">{t("joblines.fields.lbr_types.LAB")}</Select.Option>
<Select.Option value="LAD">{t("joblines.fields.lbr_types.LAD")}</Select.Option>
<Select.Option value="LAE">{t("joblines.fields.lbr_types.LAE")}</Select.Option>
<Select.Option value="LAF">{t("joblines.fields.lbr_types.LAF")}</Select.Option>
<Select.Option value="LAG">{t("joblines.fields.lbr_types.LAG")}</Select.Option>
<Select.Option value="LAM">{t("joblines.fields.lbr_types.LAM")}</Select.Option>
<Select.Option value="LAR">{t("joblines.fields.lbr_types.LAR")}</Select.Option>
<Select.Option value="LAS">{t("joblines.fields.lbr_types.LAS")}</Select.Option>
<Select.Option value="LAU">{t("joblines.fields.lbr_types.LAU")}</Select.Option>
<Select.Option value="LA1">{t("joblines.fields.lbr_types.LA1")}</Select.Option>
<Select.Option value="LA2">{t("joblines.fields.lbr_types.LA2")}</Select.Option>
<Select.Option value="LA3">{t("joblines.fields.lbr_types.LA3")}</Select.Option>
<Select.Option value="LA4">{t("joblines.fields.lbr_types.LA4")}</Select.Option>
</Select>
<Select allowClear options={CiecaSelect(false, true)} />
</Form.Item>
{Enhanced_Payroll.treatment === "on" ? (

View File

@@ -0,0 +1,87 @@
import { Progress, Space, Tag, Tooltip } from "antd";
import { useTranslation } from "react-i18next";
const parseConfidence = (confidenceStr) => {
if (!confidenceStr || typeof confidenceStr !== "string") return null;
const match = confidenceStr.match(/T([\d.]+)\s*-\s*O([\d.]+)\s*-\s*J([\d.]+)/);
if (!match) return null;
return {
total: parseFloat(match[1]),
ocr: parseFloat(match[2]),
jobMatch: parseFloat(match[3])
};
};
const getConfidenceColor = (value) => {
if (value >= 80) return "green";
if (value >= 60) return "orange";
if (value >= 40) return "gold";
return "red";
};
const ConfidenceDisplay = ({ rowValue: { confidence, actual_price, actual_cost } }) => {
const { t } = useTranslation();
const parsed = parseConfidence(confidence);
const parsed_actual_price = parseFloat(actual_price);
const parsed_actual_cost = parseFloat(actual_cost);
if (!parsed) {
return <span style={{ color: "#959595", fontSize: "0.85em" }}>N/A</span>;
}
const { total, ocr, jobMatch } = parsed;
const color = getConfidenceColor(total);
return (
<Tooltip
title={
<div style={{ padding: "4px 0" }}>
<div style={{ marginBottom: 8, fontWeight: 600 }}>{t("bills.labels.ai.confidence.breakdown")}</div>
<div style={{ marginBottom: 4 }}>
<strong>{t("bills.labels.ai.confidence.overall")}:</strong> {total.toFixed(1)}%
<Progress
percent={total}
size="small"
strokeColor={getConfidenceColor(total)}
showInfo={false}
style={{ marginTop: 2 }}
/>
</div>
<div style={{ marginBottom: 4 }}>
<strong>{t("bills.labels.ai.confidence.ocr")}:</strong> {ocr.toFixed(1)}%
<Progress
percent={ocr}
size="small"
strokeColor={getConfidenceColor(ocr)}
showInfo={false}
style={{ marginTop: 2 }}
/>
</div>
<div>
<strong>{t("bills.labels.ai.confidence.match")}:</strong> {jobMatch.toFixed(1)}%
<Progress
percent={jobMatch}
size="small"
strokeColor={getConfidenceColor(jobMatch)}
showInfo={false}
style={{ marginTop: 2 }}
/>
</div>
</div>
}
>
<Space size="small">
{!parsed_actual_cost || !parsed_actual_price || parsed_actual_cost === 0 || parsed_actual_price === 0 ? (
<Tag color="red" style={{ margin: 0, cursor: "help", userSelect: "none" }}>
{t("bills.labels.ai.confidence.missing_data")}
</Tag>
) : null}
<Tag color={color} style={{ margin: 0, cursor: "help", userSelect: "none" }}>
{total.toFixed(0)}%
</Tag>
</Space>
</Tooltip>
);
};
export default ConfidenceDisplay;

View File

@@ -9,18 +9,20 @@ import { createStructuredSelector } from "reselect";
import { selectAuthLevel, selectBodyshop } from "../../redux/user/user.selectors";
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { insertAuditTrail } from "../../redux/application/application.actions.js";
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
authLevel: selectAuthLevel
});
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
const mapDispatchToProps = (dispatch) => ({
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
});
export default connect(mapStateToProps, mapDispatchToProps)(BillMarkForReexportButton);
export function BillMarkForReexportButton({ bodyshop, authLevel, bill }) {
export function BillMarkForReexportButton({ bodyshop, authLevel, bill, insertAuditTrail }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const notification = useNotification();
@@ -47,6 +49,12 @@ export function BillMarkForReexportButton({ bodyshop, authLevel, bill }) {
notification.success({
title: t("bills.successes.reexport")
});
insertAuditTrail({
jobid: bill.jobid,
billid: bill.id,
operation: AuditTrailMapping.billmarkforreexport(bill.invoice_number),
type: "billmarkforreexport"
});
} else {
notification.error({
title: t("bills.errors.saving", {

View File

@@ -1,5 +1,5 @@
import { EditFilled, SyncOutlined } from "@ant-design/icons";
import { Button, Card, Checkbox, Input, Space, Table } from "antd";
import { Button, Card, Checkbox, Input, Space } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FaTasks } from "react-icons/fa";
@@ -18,6 +18,7 @@ import BillDetailEditReturnComponent from "../bill-detail-edit/bill-detail-edit-
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockerWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import PrintWrapperComponent from "../print-wrapper/print-wrapper.component";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
const mapStateToProps = createStructuredSelector({
@@ -237,12 +238,13 @@ export function BillsListTableComponent({
</Space>
}
>
<Table
<ResponsiveTable
loading={billsQuery.loading}
scroll={{
x: true // y: "50rem"
}}
columns={columns}
mobileColumnKeys={["vendorname", "invoice_number", "date", "total", "actions"]}
rowKey="id"
dataSource={hasBillsAccess ? filteredBills : []}
onChange={handleTableChange}

View File

@@ -3,7 +3,8 @@ import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries";
import { useQuery } from "@apollo/client/react";
import queryString from "query-string";
import { useLocation, useNavigate } from "react-router-dom";
import { Input, Table } from "antd";
import { Input } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useTranslation } from "react-i18next";
import { alphaSort } from "../../utils/sorters";
import AlertComponent from "../alert/alert.component";
@@ -79,7 +80,7 @@ export default function BillsVendorsList() {
: (data && data.vendors) || [];
return (
<Table
<ResponsiveTable
loading={loading}
title={() => {
return (
@@ -91,6 +92,7 @@ export default function BillsVendorsList() {
dataSource={dataSource}
pagination={{ placement: "top" }}
columns={columns}
mobileColumnKeys={["name", "cost_center", "city"]}
rowKey="id"
onChange={handleTableChange}
rowSelection={{

View File

@@ -19,13 +19,12 @@ export default function ChatTagRoComponent({ roOptions, loading, handleSearch, h
placeholder={t("general.labels.search")}
onSelect={handleInsertTag}
notFoundContent={loading ? <LoadingOutlined /> : <Empty />}
>
{roOptions.map((item, idx) => (
<Select.Option key={item.id || idx}>
{` ${item.ro_number || ""} | ${OwnerNameDisplayFunction(item)}`}
</Select.Option>
))}
</Select>
options={roOptions.map((item, idx) => ({
key: item.id || idx,
value: item.id || idx,
label: ` ${item.ro_number || ""} | ${OwnerNameDisplayFunction(item)}`
}))}
/>
</div>
{loading ? <LoadingOutlined /> : null}

View File

@@ -1,4 +1,5 @@
import { Card, Input, Table } from "antd";
import { Card, Input } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { alphaSort } from "../../utils/sorters";
@@ -103,10 +104,11 @@ export default function ContractsCarsComponent({ loading, data, selectedCarId, h
/>
}
>
<Table
<ResponsiveTable
loading={loading}
pagination={{ placement: "top" }}
columns={columns}
mobileColumnKeys={["status", "fleetnumber", "readiness", "year"]}
rowKey="id"
dataSource={filteredData}
onChange={handleTableChange}

View File

@@ -10,6 +10,7 @@ import { createStructuredSelector } from "reselect";
import { INSERT_NEW_JOB } from "../../graphql/jobs.queries";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
@@ -156,104 +157,127 @@ export function ContractConvertToRo({ bodyshop, currentUser, contract, disabled
joblines: {
data: billingLines
},
parts_tax_rates: {
PAA: {
prt_type: "PAA",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
...InstanceRenderManager({
imex: {
parts_tax_rates: {
PAA: {
prt_type: "PAA",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
PAC: {
prt_type: "PAC",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
PAL: {
prt_type: "PAL",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
PAM: {
prt_type: "PAM",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
PAN: {
prt_type: "PAN",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
PAR: {
prt_type: "PAR",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
PAS: {
prt_type: "PAS",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
CCDR: {
prt_type: "CCDR",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
CCF: {
prt_type: "CCF",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
CCM: {
prt_type: "CCM",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
CCC: {
prt_type: "CCC",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
CCD: {
prt_type: "CCD",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
}
}
},
PAC: {
prt_type: "PAC",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
PAL: {
prt_type: "PAL",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
PAM: {
prt_type: "PAM",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
PAN: {
prt_type: "PAN",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
PAR: {
prt_type: "PAR",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
PAS: {
prt_type: "PAS",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
CCDR: {
prt_type: "CCDR",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
CCF: {
prt_type: "CCF",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
CCM: {
prt_type: "CCM",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
CCC: {
prt_type: "CCC",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
},
CCD: {
prt_type: "CCD",
prt_discp: 0,
prt_mktyp: false,
prt_mkupp: 0,
prt_tax_in: true,
prt_tax_rt: bodyshop.bill_tax_rates.state_tax_rate / 100
rome: {
cieca_pft: {
...bodyshop.md_responsibility_centers.taxes.tax_ty1,
...bodyshop.md_responsibility_centers.taxes.tax_ty2,
...bodyshop.md_responsibility_centers.taxes.tax_ty3,
...bodyshop.md_responsibility_centers.taxes.tax_ty4,
...bodyshop.md_responsibility_centers.taxes.tax_ty5
},
materials: bodyshop.md_responsibility_centers.cieca_pfm,
cieca_pfl: bodyshop.md_responsibility_centers.cieca_pfl,
parts_tax_rates: bodyshop.md_responsibility_centers.parts_tax_rates,
tax_tow_rt: bodyshop.md_responsibility_centers.tax_tow_rt,
tax_str_rt: bodyshop.md_responsibility_centers.tax_str_rt,
tax_paint_mat_rt: bodyshop.md_responsibility_centers.tax_paint_mat_rt,
tax_shop_mat_rt: bodyshop.md_responsibility_centers.tax_shop_mat_rt,
tax_sub_rt: bodyshop.md_responsibility_centers.tax_sub_rt,
tax_lbr_rt: bodyshop.md_responsibility_centers.tax_lbr_rt,
tax_levies_rt: bodyshop.md_responsibility_centers.tax_levies_rt
}
}
})
};
if (currentUser?.email) {
@@ -287,7 +311,7 @@ export function ContractConvertToRo({ bodyshop, currentUser, contract, disabled
notification.success({
title: t("jobs.successes.created"),
onClick: () => {
history.push(`/manage/jobs/${result.data.insert_jobs.returning[0].id}`);
history(`/manage/jobs/${result.data.insert_jobs.returning[0].id}`);
}
});
}
@@ -309,13 +333,13 @@ export function ContractConvertToRo({ bodyshop, currentUser, contract, disabled
}
]}
>
<Select>
{bodyshop.md_ins_cos.map((s) => (
<Select.Option key={s.name} value={s.name}>
{s.name}
</Select.Option>
))}
</Select>
<Select
options={bodyshop.md_ins_cos.map((s) => ({
key: s.name,
value: s.name,
label: s.name
}))}
/>
</Form.Item>
<Form.Item
name={"class"}
@@ -327,13 +351,13 @@ export function ContractConvertToRo({ bodyshop, currentUser, contract, disabled
}
]}
>
<Select>
{bodyshop.md_classes.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
<Select
options={bodyshop.md_classes.map((s) => ({
key: s,
value: s,
label: s
}))}
/>
</Form.Item>
<Form.Item
label={t("contracts.labels.convertform.applycleanupcharge")}

View File

@@ -1,4 +1,5 @@
import { Card, Input, Table } from "antd";
import { Card, Input } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { alphaSort } from "../../utils/sorters";
@@ -127,10 +128,11 @@ export default function ContractsJobsComponent({ loading, data, selectedJob, han
/>
}
>
<Table
<ResponsiveTable
loading={loading}
pagination={{ placement: "top", defaultPageSize: pageLimit, defaultCurrent: defaultCurrent }}
columns={columns}
mobileColumnKeys={["ro_number", "owner", "status", "vehicle", "plate_no"]}
rowKey="id"
dataSource={filteredData}
onChange={handleTableChange}

View File

@@ -2,8 +2,6 @@ import { useEffect, useState } from "react";
import { Select } from "antd";
import { useTranslation } from "react-i18next";
const { Option } = Select;
const ContractStatusComponent = ({ value, onChange, ref }) => {
const [option, setOption] = useState(value);
const { t } = useTranslation();
@@ -15,11 +13,17 @@ const ContractStatusComponent = ({ value, onChange, ref }) => {
}, [value, option, onChange]);
return (
<Select ref={ref} value={option} style={{ width: 100 }} onChange={setOption}>
<Option value="contracts.status.new">{t("contracts.status.new")}</Option>
<Option value="contracts.status.out">{t("contracts.status.out")}</Option>
<Option value="contracts.status.returned">{t("contracts.status.out")}</Option>
</Select>
<Select
ref={ref}
value={option}
style={{ width: 100 }}
onChange={setOption}
options={[
{ value: "contracts.status.new", label: t("contracts.status.new") },
{ value: "contracts.status.out", label: t("contracts.status.out") },
{ value: "contracts.status.returned", label: t("contracts.status.out") }
]}
/>
);
};

View File

@@ -1,5 +1,6 @@
import { useLazyQuery } from "@apollo/client/react";
import { Button, Form, Modal, Table } from "antd";
import { Button, Form, Modal } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
@@ -64,7 +65,7 @@ export function ContractsFindModalContainer({ contractFinderModal, toggleModalVi
{t("general.labels.search")}
</Button>
{error && <AlertComponent type="error" title={JSON.stringify(error)} />}
<Table
<ResponsiveTable
loading={loading}
columns={[
{
@@ -124,6 +125,7 @@ export function ContractsFindModalContainer({ contractFinderModal, toggleModalVi
render: (text, record) => <DateTimeFormatter>{record.actualreturn}</DateTimeFormatter>
}
]}
mobileColumnKeys={["agreementnumber", "job.ro_number", "driver_ln", "status"]}
rowKey="id"
dataSource={data?.cccontracts}
/>

View File

@@ -1,5 +1,5 @@
import { SyncOutlined } from "@ant-design/icons";
import { Button, Card, Input, Space, Table, Typography } from "antd";
import { Button, Card, Input, Space, Typography } from "antd";
import queryString from "query-string";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -14,6 +14,7 @@ import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { pageLimit } from "../../utils/config";
import ResponsiveTable from "../responsive-table/responsive-table.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -170,13 +171,14 @@ export function ContractsList({ bodyshop, loading, contracts, refetch, total, se
}
>
<ContractsFindModalContainer />
<Table
<ResponsiveTable
loading={loading}
scroll={{
x: "50%" //y: "40rem"
}}
pagination={{ placement: "top", pageSize: pageLimit, current: parseInt(page || 1, 10), total: total }}
columns={columns}
mobileColumnKeys={["agreementnumber", "driver_ln", "status", "scheduledreturn"]}
rowKey="id"
dataSource={contracts}
onChange={handleTableChange}

View File

@@ -1,4 +1,5 @@
import { Card, Table } from "antd";
import { Card } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import queryString from "query-string";
import { useTranslation } from "react-i18next";
import { Link, useLocation, useNavigate } from "react-router-dom";
@@ -73,10 +74,11 @@ export default function CourtesyCarContractListComponent({ contracts, totalContr
return (
<Card title={t("menus.header.courtesycars-contracts")}>
<Table
<ResponsiveTable
scroll={{ x: true }}
pagination={{ placement: "top", pageSize: pageLimit, current: parseInt(page || 1), total: totalContracts }}
columns={columns}
mobileColumnKeys={["agreementnumber", "driver_ln", "status", "job.ro_number"]}
rowKey="id"
dataSource={contracts}
onChange={handleTableChange}

View File

@@ -2,8 +2,6 @@ import { Select } from "antd";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
const { Option } = Select;
const CourtesyCarReadinessComponent = ({ value, onChange, ref }) => {
const [option, setOption] = useState(value);
const { t } = useTranslation();
@@ -23,10 +21,11 @@ const CourtesyCarReadinessComponent = ({ value, onChange, ref }) => {
width: 100
}}
onChange={setOption}
>
<Option value="courtesycars.readiness.ready">{t("courtesycars.readiness.ready")}</Option>
<Option value="courtesycars.readiness.notready">{t("courtesycars.readiness.notready")}</Option>
</Select>
options={[
{ value: "courtesycars.readiness.ready", label: t("courtesycars.readiness.ready") },
{ value: "courtesycars.readiness.notready", label: t("courtesycars.readiness.notready") }
]}
/>
);
};
export default CourtesyCarReadinessComponent;

View File

@@ -2,8 +2,6 @@ import { useEffect, useState } from "react";
import { Select } from "antd";
import { useTranslation } from "react-i18next";
const { Option } = Select;
const CourtesyCarStatusComponent = ({ value, onChange, ref }) => {
const [option, setOption] = useState(value);
const { t } = useTranslation();
@@ -22,14 +20,15 @@ const CourtesyCarStatusComponent = ({ value, onChange, ref }) => {
width: 100
}}
onChange={setOption}
>
<Option value="courtesycars.status.in">{t("courtesycars.status.in")}</Option>
<Option value="courtesycars.status.inservice">{t("courtesycars.status.inservice")}</Option>
<Option value="courtesycars.status.out">{t("courtesycars.status.out")}</Option>
<Option value="courtesycars.status.sold">{t("courtesycars.status.sold")}</Option>
<Option value="courtesycars.status.leasereturn">{t("courtesycars.status.leasereturn")}</Option>
<Option value="courtesycars.status.unavailable">{t("courtesycars.status.unavailable")}</Option>
</Select>
options={[
{ value: "courtesycars.status.in", label: t("courtesycars.status.in") },
{ value: "courtesycars.status.inservice", label: t("courtesycars.status.inservice") },
{ value: "courtesycars.status.out", label: t("courtesycars.status.out") },
{ value: "courtesycars.status.sold", label: t("courtesycars.status.sold") },
{ value: "courtesycars.status.leasereturn", label: t("courtesycars.status.leasereturn") },
{ value: "courtesycars.status.unavailable", label: t("courtesycars.status.unavailable") }
]}
/>
);
};
export default CourtesyCarStatusComponent;

View File

@@ -1,5 +1,6 @@
import { SyncOutlined, WarningFilled } from "@ant-design/icons";
import { Button, Card, Dropdown, Input, Space, Table, Tooltip } from "antd";
import { Button, Card, Dropdown, Input, Space, Tooltip } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import dayjs from "../../utils/day";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -275,10 +276,11 @@ export default function CourtesyCarsList({ loading, courtesycars, refetch }) {
</Space>
}
>
<Table
<ResponsiveTable
loading={loading}
pagination={{ placement: "top" }}
columns={columns}
mobileColumnKeys={["status", "fleetnumber", "vin", "readiness"]}
rowKey="id"
dataSource={tableData}
onChange={handleTableChange}

View File

@@ -1,5 +1,6 @@
import { SyncOutlined } from "@ant-design/icons";
import { Button, Card, Table } from "antd";
import { Button, Card } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import queryString from "query-string";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -86,10 +87,11 @@ export default function CsiResponseListPaginated({ refetch, loading, responses,
return (
<Card extra={<Button onClick={() => refetch()} icon={<SyncOutlined />} />}>
<Table
<ResponsiveTable
loading={loading}
pagination={{ placement: "top", pageSize: pageLimit, current: parseInt(state.page || 1), total: total }}
columns={columns}
mobileColumnKeys={["ro_number", "owner_name", "completedon"]}
rowKey="id"
dataSource={responses}
onChange={handleTableChange}

View File

@@ -1,4 +1,5 @@
import { Card, Table, Tag } from "antd";
import { Card, Tag } from "antd";
import ResponsiveTable from "../../responsive-table/responsive-table.component";
import axios from "axios";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -182,10 +183,11 @@ export default function JobLifecycleDashboardComponent({ data, bodyshop, ...card
</div>
</Card>
<Card style={{ marginTop: "5px" }} type="inner" title={t("job_lifecycle.titles.top_durations")}>
<Table
<ResponsiveTable
size="small"
pagination={false}
columns={columns}
mobileColumnKeys={["status", "humanReadable", "averageHumanReadable", "statusCount"]}
rowKey={(record) => record.status}
dataSource={lifecycleData.summations.sort((a, b) => b.value - a.value).slice(0, 3)}
/>

View File

@@ -1,4 +1,4 @@
import { Card, Input, Space, Table, Typography } from "antd";
import { Card, Input, Space, Typography } from "antd";
import axios from "axios";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -7,6 +7,7 @@ import LoadingSkeleton from "../../loading-skeleton/loading-skeleton.component";
import Dinero from "dinero.js";
import DashboardRefreshRequired from "../refresh-required.component";
import { pageLimit } from "../../../utils/config";
import ResponsiveTable from "../../responsive-table/responsive-table.component.jsx";
export default function DashboardMonthlyJobCosting({ data, ...cardProps }) {
const { t } = useTranslation();
@@ -103,31 +104,33 @@ export default function DashboardMonthlyJobCosting({ data, ...cardProps }) {
{...cardProps}
>
<LoadingSkeleton loading={loading}>
<div style={{ height: "100%" }}>
<Table
<div style={{ height: "100%", minHeight: 0, width: "100%", overflow: "auto" }}>
<ResponsiveTable
size="small"
tableLayout="fixed"
onChange={handleTableChange}
pagination={{ placement: "top", defaultPageSize: pageLimit }}
columns={columns}
scroll={{ x: true, y: "calc(100% - 4em)" }}
scroll={{ x: "max-content" }}
rowKey="id"
style={{ height: "100%" }}
style={{ width: "100%" }}
dataSource={filteredData}
summary={() => (
<Table.Summary.Row>
<Table.Summary.Cell>
<ResponsiveTable.Summary.Row>
<ResponsiveTable.Summary.Cell>
<Typography.Title level={4}>{t("general.labels.totals")}</Typography.Title>
</Table.Summary.Cell>
<Table.Summary.Cell>
</ResponsiveTable.Summary.Cell>
<ResponsiveTable.Summary.Cell>
{Dinero(costingData?.allSummaryData && costingData.allSummaryData.totalSales).toFormat()}
</Table.Summary.Cell>
<Table.Summary.Cell>
</ResponsiveTable.Summary.Cell>
<ResponsiveTable.Summary.Cell>
{Dinero(costingData?.allSummaryData && costingData.allSummaryData.totalCost).toFormat()}
</Table.Summary.Cell>
<Table.Summary.Cell>
</ResponsiveTable.Summary.Cell>
<ResponsiveTable.Summary.Cell>
{Dinero(costingData?.allSummaryData && costingData.allSummaryData.gpdollars).toFormat()}
</Table.Summary.Cell>
<Table.Summary.Cell></Table.Summary.Cell>
</Table.Summary.Row>
</ResponsiveTable.Summary.Cell>
<ResponsiveTable.Summary.Cell></ResponsiveTable.Summary.Cell>
</ResponsiveTable.Summary.Row>
)}
/>
</div>

View File

@@ -363,6 +363,7 @@ export default function DashboardScheduledDeliveryToday({ data, ...cardProps })
onChange={handleTableChange}
pagination={false}
columns={isTvModeScheduledDelivery ? tvColumns : columns}
// mobileColumnKeys={["ro_number", "owner", "status", "vehicle"]}
scroll={{ x: true, y: "calc(100% - 2em)" }}
rowKey="id"
style={{ height: "85%" }}

View File

@@ -368,6 +368,7 @@ export default function DashboardScheduledInToday({ data, ...cardProps }) {
onChange={handleTableChange}
pagination={false}
columns={isTvModeScheduledIn ? tvColumns : columns}
mobileColumnKeys={["ro_number", "owner", "vehicle", "start"]}
scroll={{ x: true, y: "calc(100% - 2em)" }}
rowKey="id"
style={{ height: "85%" }}

View File

@@ -363,6 +363,7 @@ export default function DashboardScheduledOutToday({ data, ...cardProps }) {
onChange={handleTableChange}
pagination={false}
columns={isTvModeScheduledOut ? tvColumns : columns}
// mobileColumnKeys={["ro_number", "owner", "status", "vehicle"]}
scroll={{ x: true, y: "calc(100% - 2em)" }}
rowKey="id"
style={{ height: "85%" }}

View File

@@ -1,5 +1,6 @@
import { SyncOutlined } from "@ant-design/icons";
import { Button, Card, Form, Input, Table } from "antd";
import { Button, Card, Form, Input } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
@@ -78,7 +79,7 @@ export function DmsAllocationsSummaryAp({ socket, bodyshop, billids, title }) {
dataIndex: "Lines",
key: "Lines",
render: (text, record) => (
<table style={{ tableLayout: "auto", width: "100%" }}>
<ResponsiveTable style={{ tableLayout: "auto", width: "100%" }}>
<tr>
<th>{t("bills.fields.invoice_number")}</th>
<th>{t("bodyshop.fields.dms.dms_acctnumber")}</th>
@@ -91,7 +92,7 @@ export function DmsAllocationsSummaryAp({ socket, bodyshop, billids, title }) {
<td>{l.Amount}</td>
</tr>
))}
</table>
</ResponsiveTable>
)
}
];
@@ -115,9 +116,10 @@ export function DmsAllocationsSummaryAp({ socket, bodyshop, billids, title }) {
/>
}
>
<Table
<ResponsiveTable
pagination={{ placement: "top", defaultPageSize: pageLimit }}
columns={columns}
mobileColumnKeys={["status", "reference", "Lines"]}
rowKey={(record) => `${record.InvoiceNumber}${record.Account}`}
dataSource={allocationsSummary}
locale={{ emptyText: t("dms.labels.refreshallocations") }}

View File

@@ -1,4 +1,5 @@
import { Alert, Button, Card, Table, Typography } from "antd";
import { Alert, Button, Card, Typography } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { SyncOutlined } from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -116,9 +117,10 @@ export function DmsAllocationsSummary({ mode, socket, bodyshop, jobId, title, on
<Alert type="warning" title={t("jobs.labels.dms.disablebillwip")} />
)}
<Table
<ResponsiveTable
pagination={{ placement: "top", defaultPageSize: pageLimit }}
columns={columns}
mobileColumnKeys={["center", "sale", "cost", "sale_dms_acctnumber"]}
rowKey="center"
dataSource={allocationsSummary}
locale={{ emptyText: t("dms.labels.refreshallocations") }}
@@ -135,15 +137,17 @@ export function DmsAllocationsSummary({ mode, socket, bodyshop, jobId, title, on
const hasNonZeroSaleTotal = totals.totalSale.getAmount() !== 0;
return (
<Table.Summary.Row>
<Table.Summary.Cell>
<ResponsiveTable.Summary.Row>
<ResponsiveTable.Summary.Cell>
<Typography.Title level={4}>{t("general.labels.totals")}</Typography.Title>
</Table.Summary.Cell>
<Table.Summary.Cell>{hasNonZeroSaleTotal ? totals.totalSale.toFormat() : null}</Table.Summary.Cell>
<Table.Summary.Cell />
<Table.Summary.Cell />
<Table.Summary.Cell />
</Table.Summary.Row>
</ResponsiveTable.Summary.Cell>
<ResponsiveTable.Summary.Cell>
{hasNonZeroSaleTotal ? totals.totalSale.toFormat() : null}
</ResponsiveTable.Summary.Cell>
<ResponsiveTable.Summary.Cell />
<ResponsiveTable.Summary.Cell />
<ResponsiveTable.Summary.Cell />
</ResponsiveTable.Summary.Row>
);
}}
/>

View File

@@ -1,4 +1,5 @@
import { Alert, Button, Card, Table, Tabs, Typography } from "antd";
import { Alert, Button, Card, Tabs, Typography } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { SyncOutlined } from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -63,7 +64,7 @@ function normalizeJobAllocations(ack) {
* RR-specific DMS Allocations Summary
* Focused on what we actually send to RR:
* - 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)
* is now done on the backend via buildRogogFromAllocations/buildRolaborFromRogog.
@@ -180,21 +181,30 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
const rolaborRows = useMemo(() => {
if (!rolaborPreview || !Array.isArray(rolaborPreview.ops)) return [];
return rolaborPreview.ops.map((op, idx) => {
const rowOpCode = opCode || op.opCode;
return rolaborPreview.ops
.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 {
key: `${op.jobNo}-${idx}`,
opCode: rowOpCode,
jobNo: op.jobNo,
custPayTypeFlag: op.custPayTypeFlag,
custTxblNtxblFlag: op.custTxblNtxblFlag,
payType: op.bill?.payType,
amtType: op.amount?.amtType,
custPrice: op.amount?.custPrice,
totalAmt: op.amount?.totalAmt
};
});
return {
key: `${op.jobNo}-${idx}`,
opCode: rowOpCode,
jobNo: op.jobNo,
custPayTypeFlag: op.custPayTypeFlag,
custTxblNtxblFlag: op.custTxblNtxblFlag,
payType: op.bill?.payType,
jobTotalHrs: op.bill?.jobTotalHrs,
billTime: op.bill?.billTime,
billRate: op.bill?.billRate,
amtType: op.amount?.amtType,
custPrice: op.amount?.custPrice,
totalAmt: op.amount?.totalAmt
};
});
}, [rolaborPreview, opCode]);
// Totals for ROGOG (sum custPrice + dlrCost over all lines)
@@ -244,6 +254,9 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
{ title: "CustPayType", dataIndex: "custPayTypeFlag", key: "custPayTypeFlag" },
{ title: "CustTxblFlag", dataIndex: "custTxblNtxblFlag", key: "custTxblNtxblFlag" },
{ 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: "CustPrice", dataIndex: "custPrice", key: "custPrice" },
{ title: "TotalAmt", dataIndex: "totalAmt", key: "totalAmt" }
@@ -261,9 +274,10 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
into taxable / non-taxable segments.
</Typography.Paragraph>
<Table
<ResponsiveTable
pagination={false}
columns={roggColumns}
mobileColumnKeys={["jobNo", "opCode", "breakOut", "itemType"]}
rowKey="key"
dataSource={roggRows}
locale={{ emptyText: "No ROGOG lines would be generated." }}
@@ -286,19 +300,23 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
const hasCostTotal = Number(roggTotals.totalDlrCost) !== 0;
return (
<Table.Summary.Row>
<Table.Summary.Cell index={0}>
<ResponsiveTable.Summary.Row>
<ResponsiveTable.Summary.Cell index={0}>
<Typography.Title level={5}>{t("general.labels.totals")}</Typography.Title>
</Table.Summary.Cell>
<Table.Summary.Cell index={1} />
<Table.Summary.Cell index={2} />
<Table.Summary.Cell index={3} />
<Table.Summary.Cell index={4} />
<Table.Summary.Cell index={5} />
<Table.Summary.Cell index={6} />
<Table.Summary.Cell index={7}>{hasCustTotal ? roggTotals.totalCustPrice : null}</Table.Summary.Cell>
<Table.Summary.Cell index={8}>{hasCostTotal ? roggTotals.totalDlrCost : null}</Table.Summary.Cell>
</Table.Summary.Row>
</ResponsiveTable.Summary.Cell>
<ResponsiveTable.Summary.Cell index={1} />
<ResponsiveTable.Summary.Cell index={2} />
<ResponsiveTable.Summary.Cell index={3} />
<ResponsiveTable.Summary.Cell index={4} />
<ResponsiveTable.Summary.Cell index={5} />
<ResponsiveTable.Summary.Cell index={6} />
<ResponsiveTable.Summary.Cell index={7}>
{hasCustTotal ? roggTotals.totalCustPrice : null}
</ResponsiveTable.Summary.Cell>
<ResponsiveTable.Summary.Cell index={8}>
{hasCostTotal ? roggTotals.totalDlrCost : null}
</ResponsiveTable.Summary.Cell>
</ResponsiveTable.Summary.Row>
);
}}
/>
@@ -311,11 +329,13 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
children: (
<>
<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>
<Table
<ResponsiveTable
pagination={false}
columns={rolaborColumns}
mobileColumnKeys={["jobNo", "opCode", "billRate", "custPrice"]}
rowKey="key"
dataSource={rolaborRows}
locale={{ emptyText: "No ROLABOR lines would be generated." }}

View File

@@ -1,5 +1,6 @@
import { useLazyQuery } from "@apollo/client/react";
import { Button, Input, Modal, Table } from "antd";
import { Button, Input, Modal } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
@@ -60,7 +61,7 @@ export function DmsCdkVehicles({ form, job }) {
okButtonProps={{ disabled: !selectedModel }}
>
{error && <AlertComponent title={error.message} type="error" />}
<Table
<ResponsiveTable
title={() => (
<Input.Search
onSearch={(val) => callSearch({ variables: { search: val } })}
@@ -69,6 +70,7 @@ export function DmsCdkVehicles({ form, job }) {
/>
)}
columns={columns}
mobileColumnKeys={["make", "model", "makecode", "modelcode"]}
loading={loading}
rowKey="id"
dataSource={data ? data.search_dms_vehicles : []}

View File

@@ -1,4 +1,5 @@
import { Button, Checkbox, Col, Table } from "antd";
import { Button, Checkbox, Col } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { alphaSort } from "../../utils/sorters";
@@ -72,7 +73,7 @@ export default function CDKCustomerSelector({ bodyshop, socket }) {
return (
<Col span={24}>
<Table
<ResponsiveTable
title={() => (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Button onClick={onUseSelected} disabled={!selectedCustomer}>
@@ -86,6 +87,7 @@ export default function CDKCustomerSelector({ bodyshop, socket }) {
)}
pagination={{ placement: "top" }}
columns={columns}
mobileColumnKeys={["id", "vinOwner", "name1", "address"]}
rowKey={rowKey}
dataSource={customerList}
rowSelection={{

View File

@@ -1,4 +1,5 @@
import { Button, Checkbox, Col, Table } from "antd";
import { Button, Checkbox, Col } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { alphaSort } from "../../utils/sorters";
@@ -78,7 +79,7 @@ export default function FortellisCustomerSelector({ bodyshop, jobid, socket }) {
return (
<Col span={24}>
<Table
<ResponsiveTable
title={() => (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Button onClick={onUseSelected} disabled={!selectedCustomer}>
@@ -92,6 +93,7 @@ export default function FortellisCustomerSelector({ bodyshop, jobid, socket }) {
)}
pagination={{ placement: "top" }}
columns={columns}
mobileColumnKeys={["id", "vinOwner", "firstName", "address"]}
rowKey={(r) => r.customerId}
dataSource={customerList}
rowSelection={{

View File

@@ -1,4 +1,5 @@
import { Button, Col, Table } from "antd";
import { Button, Checkbox, Col } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { alphaSort } from "../../utils/sorters";
@@ -48,7 +49,13 @@ export default function PBSCustomerSelector({ bodyshop, socket }) {
if (!open) return null;
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"),
key: "name1",
@@ -66,7 +73,7 @@ export default function PBSCustomerSelector({ bodyshop, socket }) {
return (
<Col span={24}>
<Table
<ResponsiveTable
title={() => (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Button onClick={onUseSelected} disabled={!selectedCustomer}>
@@ -80,6 +87,7 @@ export default function PBSCustomerSelector({ bodyshop, socket }) {
)}
pagination={{ placement: "top" }}
columns={columns}
mobileColumnKeys={["ContactId", "name1", "address"]}
rowKey={(r) => r.ContactId}
dataSource={customerList}
rowSelection={{

View File

@@ -1,4 +1,5 @@
import { Alert, Button, Checkbox, message, Modal, Space, Table } from "antd";
import { Alert, Button, Checkbox, message, Modal, Space } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { alphaSort } from "../../utils/sorters";
@@ -72,14 +73,14 @@ export default function RRCustomerSelector({
if (!socket) return;
const handleRrSelectCustomer = (list) => {
const normalized = normalizeRrList(list);
// If list is empty, it means early RO exists and customer selection should be skipped
// Don't open the modal in this case
if (normalized.length === 0) {
setRefreshing(false);
return;
}
setOpen(true);
setCustomerList(normalized);
const firstOwner = normalized.find((r) => r.vinOwner)?.custNo;
@@ -195,8 +196,8 @@ export default function RRCustomerSelector({
description={
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div>
We created the Repair Order. Please validate the totals and taxes in the DMS system. When done,
click <strong>Finished</strong> to finalize and mark this export as complete.
We created the Repair Order. Please validate the totals and taxes in the DMS system. When done, click{" "}
<strong>Finished</strong> to finalize and mark this export as complete.
</div>
<div>
<Space>
@@ -215,14 +216,8 @@ export default function RRCustomerSelector({
}
return (
<Modal
open={open}
onCancel={handleClose}
footer={null}
width={800}
title={t("dms.selectCustomer")}
>
<Table
<Modal open={open} onCancel={handleClose} footer={null} width={800} title={t("dms.selectCustomer")}>
<ResponsiveTable
title={() => (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{/* Open RO limit banner */}
@@ -304,6 +299,7 @@ export default function RRCustomerSelector({
)}
pagination={{ placement: "top" }}
columns={columns}
mobileColumnKeys={["custNo", "vinOwner", "name", "address"]}
rowKey={(r) => r.custNo}
dataSource={customerList}
rowSelection={{

View File

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

View File

@@ -272,11 +272,19 @@ export default function CdkLikePostForm({ bodyshop, socket, job, logsRef, mode,
name={[field.name, "name"]}
rules={[{ required: true }]}
>
<Select style={{ width: "100%" }} onSelect={(value) => handlePayerSelect(value, index)}>
{bodyshop.cdk_configuration?.payers?.map((payer) => (
<Select.Option key={payer.name}>{payer.name}</Select.Option>
))}
</Select>
<Select
showSearch={{
optionFilterProp: "label",
filterOption: (input, option) => option.label.toLowerCase().includes(input.toLowerCase())
}}
style={{ width: "100%" }}
onSelect={(value) => handlePayerSelect(value, index)}
options={bodyshop.cdk_configuration?.payers?.map((payer) => ({
key: payer.name,
value: payer.name,
label: payer.name
}))}
/>
</Form.Item>
</Col>

View File

@@ -1,5 +1,6 @@
import { ReloadOutlined } from "@ant-design/icons";
import { Alert, Button, Form, Input, InputNumber, Modal, Radio, Select, Space, Table, Typography } from "antd";
import { Alert, Button, Form, Input, InputNumber, Modal, Radio, Select, Space, Typography } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useEffect, useMemo, useState } from "react";
// Simple customer selector table
@@ -26,7 +27,14 @@ function CustomerSelectorTable({ customers, onSelect, isSubmitting }) {
return (
<div>
<Table columns={columns} dataSource={customers} rowKey="custNo" pagination={false} size="small" />
<ResponsiveTable
columns={columns}
mobileColumnKeys={["name", "select", "custNo", "vinOwner"]}
dataSource={customers}
rowKey="custNo"
pagination={false}
size="small"
/>
<div style={{ marginTop: 16, display: "flex", gap: 8 }}>
<Button
type="primary"

View File

@@ -1,5 +1,5 @@
import axios from "axios";
import { Result } from "antd";
import { Result, theme } from "antd";
import * as markerjs2 from "markerjs2";
import { useCallback, useEffect, useRef, useState } from "react";
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 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({
currentUser: selectCurrentUser,
@@ -24,7 +30,9 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const markerArea = useRef(null);
const imageHistory = useRef([]);
const { t } = useTranslation();
const { token } = theme.useToken();
const notification = useNotification();
const [uploading, setUploading] = useState(false);
@@ -32,6 +40,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
async (dataUrl) => {
if (uploading) return;
setUploading(true);
setLoading(true);
const blob = await b64toBlob(dataUrl);
const nameWithoutExt = filename.split(".").slice(0, -1).join(".").trim();
const parts = nameWithoutExt.split("-");
@@ -70,6 +79,23 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
[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(() => {
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
// create a marker.js MarkerArea
@@ -93,8 +119,10 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
markerArea.current.renderImageQuality = 1;
//markerArea.current.settings.displayMode = "inline";
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(() => {
if (!imageUrl) return;
@@ -106,6 +134,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
try {
const response = await axios.get(imageUrl, { responseType: "blob", signal: controller.signal });
const blobUrl = URL.createObjectURL(response.data);
imageHistory.current = [];
setLoadedImageUrl((prevUrl) => {
if (prevUrl) URL.revokeObjectURL(prevUrl);
return blobUrl;
@@ -142,7 +171,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
}
return (
<div>
<div style={{ background: token.colorBgBase, color: token.colorText, minHeight: "100vh" }}>
{!loading && !uploaded && loadedImageUrl && (
<img
ref={imgRef}
@@ -158,7 +187,12 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
{(loading || imageLoading || !imageLoaded) && !uploaded && (
<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>
);
}

View File

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

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 { INSERT_NEW_DOCUMENT } from "../../graphql/documents.queries";
import { axiosAuthInterceptorId } from "../../utils/CleanAxios";
import { replaceAccents } from "../../utils/replaceAccents.js";
import client from "../../utils/GraphQLClient";
//Context: currentUserEmail, bodyshop, jobid, invoiceid
@@ -144,32 +145,3 @@ export const uploadToS3 = async (
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

@@ -41,7 +41,7 @@ export function EmailOverlayComponent({ emailConfig, form, selectedMediaState, b
const emailsToMenu = {
items: [
...bodyshop.employees
.filter((e) => e.user_email)
.filter((e) => e.user_email && e.active === true)
.map((e, idx) => ({
key: idx,
label: `${e.first_name} ${e.last_name}`,
@@ -59,7 +59,7 @@ export function EmailOverlayComponent({ emailConfig, form, selectedMediaState, b
const menuCC = {
items: [
...bodyshop.employees
.filter((e) => e.user_email)
.filter((e) => e.user_email && e.active === true)
.map((e, idx) => ({
key: idx,
label: `${e.first_name} ${e.last_name}`,
@@ -86,11 +86,13 @@ export function EmailOverlayComponent({ emailConfig, form, selectedMediaState, b
}
]}
>
<Select>
<Select.Option key={currentUser.email}>{currentUser.email}</Select.Option>
<Select.Option key={bodyshop.email}>{bodyshop.email}</Select.Option>
{bodyshop.md_from_emails && bodyshop.md_from_emails.map((e) => <Select.Option key={e}>{e}</Select.Option>)}
</Select>
<Select
options={[
{ key: currentUser.email, value: currentUser.email, label: currentUser.email },
{ key: bodyshop.email, value: bodyshop.email, label: bodyshop.email },
...(bodyshop.md_from_emails ? bodyshop.md_from_emails.map((e) => ({ key: e, value: e, label: e })) : [])
]}
/>
</Form.Item>
<Form.Item
label={

View File

@@ -163,7 +163,7 @@ export function EmailOverlayContainer({ emailConfig, modalVisible, toggleEmailOv
<Modal
destroyOnHidden
open={modalVisible}
maskClosable={false}
mask={{ closable: false }}
width={"80%"}
onOk={() => form.submit()}
title={t("emails.labels.emailpreview")}

View File

@@ -1,7 +1,6 @@
import { Select, Space, Tag } from "antd";
import { useTranslation } from "react-i18next";
const { Option } = Select;
//To be used as a form element only.
const EmployeeSearchSelectEmail = ({ options, ...props }) => {
@@ -12,26 +11,24 @@ const EmployeeSearchSelectEmail = ({ options, ...props }) => {
showSearch={{
optionFilterProp: "search"
}}
// value={option}
style={{
width: 400
}}
options={options?.map((o) => ({
key: o.id,
value: o.user_email,
search: `${o.employee_number} ${o.first_name} ${o.last_name}`,
label: (
<Space>
{`${o.employee_number} ${o.first_name} ${o.last_name}`}
<Tag color="green">
{o.flat_rate ? t("timetickets.labels.flat_rate") : t("timetickets.labels.straight_time")}
</Tag>
</Space>
)
}))}
{...props}
>
{options
? options.map((o) => (
<Option key={o.id} value={o.user_email} search={`${o.employee_number} ${o.first_name} ${o.last_name}`}>
<Space>
{`${o.employee_number} ${o.first_name} ${o.last_name}`}
<Tag color="green">
{o.flat_rate ? t("timetickets.labels.flat_rate") : t("timetickets.labels.straight_time")}
</Tag>
</Space>
</Option>
))
: null}
</Select>
/>
);
};
export default EmployeeSearchSelectEmail;

View File

@@ -1,7 +1,6 @@
import { Select, Space, Tag } from "antd";
import { useTranslation } from "react-i18next";
const { Option } = Select;
//To be used as a form element only.
const EmployeeSearchSelect = ({ options, showEmail, ...props }) => {
@@ -12,30 +11,29 @@ const EmployeeSearchSelect = ({ options, showEmail, ...props }) => {
showSearch={{
optionFilterProp: "search"
}}
// value={option}
style={{
width: 400
}}
options={options?.map((o) => ({
key: o.id,
value: o.id,
search: `${o.employee_number} ${o.first_name} ${o.last_name}`,
label: (
<Space size="small">
{`${o.employee_number ?? ""} ${o.first_name} ${o.last_name}`}
<Tag color="green" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
{o.flat_rate ? t("timetickets.labels.flat_rate") : t("timetickets.labels.straight_time")}
</Tag>
{showEmail && o.user_email ? (
<Tag color="blue" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
{o.user_email}
</Tag>
) : null}
</Space>
)
}))}
{...props}
>
{options
? options.map((o) => (
<Option key={o.id} value={o.id} search={`${o.employee_number} ${o.first_name} ${o.last_name}`}>
<Space size="small">
{`${o.employee_number ?? ""} ${o.first_name} ${o.last_name}`}
<Tag color="green" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
{o.flat_rate ? t("timetickets.labels.flat_rate") : t("timetickets.labels.straight_time")}
</Tag>
{showEmail && o.user_email ? (
<Tag color="blue" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
{o.user_email}
</Tag>
) : null}
</Space>
</Option>
))
: null}
</Select>
/>
);
};
export default EmployeeSearchSelect;

View File

@@ -0,0 +1,92 @@
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, jobId, setEsignatureContext }) {
const [loading, setLoading] = useState(false);
const notification = useNotification();
const { t } = useTranslation();
if (!hasDocumensoApiKey(bodyshop)) {
return null;
}
const uploadCustomDocument = async ({ file, onError, onSuccess }) => {
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}
maxCount={1}
showUploadList={false}
multiple={false}
>
<Button icon={<UploadOutlined />} loading={loading}>
{t("esignature.actions.upload_document")}
</Button>
</Upload>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(EsignatureCustomDocument);

View File

@@ -0,0 +1,101 @@
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);
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 () => {
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 () => {
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 }}
okText={t("esignature.actions.distribute")}
destroyOnHidden
width={"80%"}
>
<div style={{ height: "80vh", width: "100%" }}>
{token ? (
<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

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

View File

@@ -4,20 +4,203 @@ import AlertComponent from "../alert/alert.component";
import "./form-fields-changed.styles.scss";
import Prompt from "../../utils/prompt";
export default function FormsFieldChanged({ form, skipPrompt }) {
export default function FormsFieldChanged({ form, skipPrompt, onErrorNavigate, onReset, onDirtyChange }) {
const { t } = useTranslation();
const normalizeNamePath = (namePath) => (Array.isArray(namePath) ? namePath.filter((part) => part !== undefined) : [namePath]);
const getFieldIdCandidates = (namePath) => {
const normalizedNamePath = normalizeNamePath(namePath).map((part) => String(part));
const underscoreId = normalizedNamePath.join("_");
const dashId = normalizedNamePath.join("-");
const dotName = normalizedNamePath.join(".");
return [underscoreId, dashId, dotName].filter(Boolean);
};
const clearFormMeta = () => {
const fieldMeta = form.getFieldsError().map(({ name }) => ({
name,
touched: false,
validating: false,
errors: [],
warnings: []
}));
if (fieldMeta.length > 0) {
form.setFields(fieldMeta);
}
onDirtyChange?.(false);
};
const handleReset = () => {
form.resetFields();
if (onReset) {
onReset();
} else {
form.resetFields();
}
window.requestAnimationFrame(() => {
clearFormMeta();
});
};
const getFieldDomNode = (namePath) => {
const fieldInstance = form.getFieldInstance?.(namePath);
const fieldIdCandidates = getFieldIdCandidates(namePath);
const domCandidates = [
fieldInstance?.nativeElement,
fieldInstance?.input,
fieldInstance?.resizableTextArea?.textArea,
fieldInstance
];
fieldIdCandidates.forEach((fieldId) => {
const escapedFieldId = CSS.escape(fieldId);
const directNode = document.getElementById(fieldId) || document.querySelector(`#${escapedFieldId}`);
const labelNode = document.querySelector(`label[for="${escapedFieldId}"]`);
const namedNode = document.querySelector(`[name="${escapedFieldId}"]`);
const formItemNode =
directNode?.closest?.(".ant-form-item") ||
labelNode?.closest?.(".ant-form-item") ||
namedNode?.closest?.(".ant-form-item");
domCandidates.push(directNode);
domCandidates.push(namedNode);
domCandidates.push(formItemNode);
domCandidates.push(formItemNode?.querySelector?.("input, textarea, select, .ant-select-selector"));
});
return domCandidates.find((candidate) => candidate instanceof HTMLElement) ?? null;
};
const waitForAnimationFrames = (frameCount = 1) =>
new Promise((resolve) => {
let remainingFrames = frameCount;
const nextFrame = () => {
if (remainingFrames <= 0) {
resolve();
return;
}
remainingFrames -= 1;
window.requestAnimationFrame(nextFrame);
};
window.requestAnimationFrame(nextFrame);
});
const getFieldOwningTabMeta = (namePath) => {
const fieldDomNode = getFieldDomNode(namePath);
const owningTabPane = fieldDomNode?.closest?.(".ant-tabs-tabpane");
const paneId = owningTabPane?.getAttribute?.("id") || null;
const owningTabButton = paneId
? document.querySelector(`[role="tab"][aria-controls="${paneId.replace(/"/g, '\\"')}"]`)
: null;
const tabLabel = owningTabButton?.textContent?.trim() || null;
return {
owningTabPane,
owningTabButton,
tabLabel
};
};
const openFieldOwningTab = async (namePath) => {
const { owningTabPane, owningTabButton } = getFieldOwningTabMeta(namePath);
if (!owningTabPane || owningTabPane.classList.contains("ant-tabs-tabpane-active")) return false;
if (!(owningTabButton instanceof HTMLElement)) return false;
owningTabButton.click();
for (let index = 0; index < 24; index += 1) {
await waitForAnimationFrames();
if (owningTabPane.classList.contains("ant-tabs-tabpane-active")) return true;
}
return owningTabPane.classList.contains("ant-tabs-tabpane-active");
};
const scrollToErrorField = (namePath) => {
const normalizedNamePath = normalizeNamePath(namePath);
if (!normalizedNamePath.length) return;
try {
form.scrollToField(normalizedNamePath, {
behavior: "smooth",
block: "center",
focus: true
});
window.requestAnimationFrame(() => {
const fallbackNode = getFieldDomNode(normalizedNamePath);
fallbackNode?.focus?.();
});
return;
} catch {
const fallbackTarget = document.getElementById(normalizedNamePath[0]?.toString?.() ?? "");
fallbackTarget?.scrollIntoView({
behavior: "smooth",
block: "center"
});
}
};
const handleErrorClick = async (namePath) => {
const normalizedNamePath = normalizeNamePath(namePath);
if (!normalizedNamePath.length) return;
const switchedTab = await openFieldOwningTab(normalizedNamePath);
if (!switchedTab) {
const navigationDelayMs = onErrorNavigate?.(normalizedNamePath) ?? 0;
if (navigationDelayMs > 0) {
window.setTimeout(() => {
window.requestAnimationFrame(() => {
scrollToErrorField(normalizedNamePath);
});
}, navigationDelayMs);
return;
}
}
await waitForAnimationFrames(switchedTab ? 2 : 1);
scrollToErrorField(normalizedNamePath);
};
//if (!form.isFieldsTouched()) return <></>;
return (
<Form.Item className="form-fields-changed" shouldUpdate style={{ margin: 0, padding: 0, minHeight: "unset" }}>
{() => {
const errors = form.getFieldsError().filter((e) => e.errors.length > 0);
const errors = form
.getFieldsError()
.filter((fieldError) => fieldError.errors.length > 0)
.flatMap((fieldError) => {
const tabMeta = getFieldOwningTabMeta(fieldError.name);
return fieldError.errors.map((errorMessage, errorIndex) => ({
key: `${(fieldError.name || []).join(".")}-${errorIndex}-${errorMessage}`,
message: errorMessage,
namePath: fieldError.name,
tabLabel: tabMeta.tabLabel
}));
});
const groupedErrors = errors.reduce((groups, error) => {
const groupKey = error.tabLabel || "__ungrouped__";
if (!groups[groupKey]) {
groups[groupKey] = {
key: groupKey,
label: error.tabLabel,
errors: []
};
}
groups[groupKey].errors.push(error);
return groups;
}, {});
const errorGroups = Object.values(groupedErrors);
const hasTabbedErrorGroups = errorGroups.some((group) => Boolean(group.label));
if (form.isFieldsTouched())
return (
<Space orientation="vertical" style={{ width: "100%" }}>
<Space orientation="vertical" style={{ width: "100%", marginBottom: 10 }}>
<Prompt when={!skipPrompt} beforeUnload={true} message={t("general.messages.unsavedchangespopup")} />
<AlertComponent
type="warning"
@@ -39,11 +222,38 @@ export default function FormsFieldChanged({ form, skipPrompt }) {
{errors.length > 0 && (
<AlertComponent
type="error"
title={
<div>
<ul>{errors.map((e, idx) => e.errors.map((e2, idx2) => <li key={`${idx}${idx2}`}>{e2}</li>))}</ul>
title={t("general.labels.validationerror")}
description={
<div className="form-fields-changed__error-groups">
{errorGroups.map((group) => (
<div key={group.key} className="form-fields-changed__error-group">
{hasTabbedErrorGroups && group.label ? (
<div className="form-fields-changed__error-group-title">{group.label}</div>
) : null}
<ul className="form-fields-changed__error-list">
{group.errors.map((error) => (
<li key={error.key}>
{Array.isArray(error.namePath) && error.namePath.length > 0 ? (
<button
type="button"
className="form-fields-changed__error-link"
onClick={() => {
handleErrorClick(error.namePath);
}}
>
{error.message}
</button>
) : (
error.message
)}
</li>
))}
</ul>
</div>
))}
</div>
}
showIcon
/>
)}
</Space>

View File

@@ -4,4 +4,47 @@
min-height: unset !important;
}
}
&__error-list {
margin: 0;
padding-left: 18px;
}
&__error-groups {
display: grid;
gap: 10px;
}
&__error-group {
display: grid;
gap: 4px;
}
&__error-group-title {
font-weight: 600;
}
&__error-link {
display: inline;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
line-height: inherit;
text-align: left;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
color: color-mix(in srgb, var(--ant-color-error) 82%, var(--ant-color-text));
}
&:focus-visible {
outline: 2px solid color-mix(in srgb, var(--ant-color-error) 32%, transparent);
outline-offset: 2px;
border-radius: 4px;
}
}
}

View File

@@ -1,11 +1,88 @@
import { Input } from "antd";
import { PhoneFilled } from "@ant-design/icons";
import { Button, Input, Space } from "antd";
import i18n from "i18next";
import parsePhoneNumber from "libphonenumber-js";
import { forwardRef, useMemo, useState } from "react";
import "./phone-form-item.styles.scss";
function FormItemPhone({ ref, ...props }) {
return <Input ref={ref} {...props} />;
}
/**
* Formats a phone number for display purposes. If the input value is a valid phone number, it will be formatted in a
* national format (e.g., (123) 456-7890 for US/CA). If the input is not a valid phone number, it will be returned as-is.
* @param value
* @returns {*}
*/
const formatPhoneDisplayValue = (value) => {
if (!value) return value;
try {
const parsedPhone = parsePhoneNumber(value, "CA");
return parsedPhone?.isValid() ? parsedPhone.formatNational() : value;
} catch {
return value;
}
};
/**
* Generates a "tel:" URL for a phone number if it's valid. If the input value is a valid phone number, it will return a
* URL in the format "tel:+1234567890". If the input is not a valid phone number, it will attempt to trim whitespace and
* return a "tel:" URL with the raw value, or null if the trimmed value is empty.
* @param value
* @returns {string|null}
*/
const getPhoneActionHref = (value) => {
if (!value) return null;
try {
const parsedPhone = parsePhoneNumber(value, "CA");
if (parsedPhone?.isValid()) return `tel:${parsedPhone.number}`;
} catch {
// Fall back to the raw value below.
}
const trimmedValue = String(value).trim();
return trimmedValue ? `tel:${trimmedValue}` : null;
};
const FormItemPhone = forwardRef(function FormItemPhone(
{ formatDisplayOnly = false, showPhoneAction = false, value, onBlur, onFocus, ...props },
ref
) {
const [isFocused, setIsFocused] = useState(false);
const displayValue = useMemo(() => {
if (!formatDisplayOnly || isFocused) return value;
return formatPhoneDisplayValue(value);
}, [formatDisplayOnly, isFocused, value]);
const phoneActionHref = useMemo(() => (showPhoneAction ? getPhoneActionHref(value) : null), [showPhoneAction, value]);
const input = (
<Input
ref={ref}
{...props}
value={displayValue}
onFocus={(event) => {
setIsFocused(true);
onFocus?.(event);
}}
onBlur={(event) => {
setIsFocused(false);
onBlur?.(event);
}}
/>
);
if (!showPhoneAction) return input;
return (
<Space.Compact style={{ width: "100%" }}>
{input}
{phoneActionHref ? (
<Button icon={<PhoneFilled />} href={phoneActionHref} />
) : (
<Button icon={<PhoneFilled />} disabled />
)}
</Space.Compact>
);
});
export default FormItemPhone;

View File

@@ -10,8 +10,13 @@ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
const toFiniteNumber = (value) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
if (!value) return null;
if (value === null || value === undefined || value === "") return null;
switch (type) {
case "employee": {
const emp = bodyshop.employees.find((e) => e.id === value);
@@ -20,8 +25,15 @@ const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
case "text":
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
case "currency":
return <div>{Dinero({ amount: Math.round(value * 100) }).toFormat()}</div>;
case "currency": {
const numericValue = toFiniteNumber(value);
if (numericValue === null) {
return null;
}
return <div>{Dinero({ amount: Math.round(numericValue * 100) }).toFormat()}</div>;
}
default:
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
}

View File

@@ -0,0 +1,34 @@
import { LinkOutlined } from "@ant-design/icons";
import { Button, Input, Space } from "antd";
import { forwardRef, useMemo } from "react";
const HAS_URL_PROTOCOL_REGEX = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
const LOCALHOST_OR_IP_REGEX = /^(localhost|127(?:\.\d{1,3}){3}|\d{1,3}(?:\.\d{1,3}){3})(:\d+)?(\/.*)?$/i;
const getUrlActionHref = (value) => {
const trimmedValue = String(value ?? "").trim();
if (!trimmedValue) return null;
if (HAS_URL_PROTOCOL_REGEX.test(trimmedValue)) return trimmedValue;
if (trimmedValue.startsWith("//")) return `https:${trimmedValue}`;
if (LOCALHOST_OR_IP_REGEX.test(trimmedValue)) return `http://${trimmedValue}`;
return `https://${trimmedValue}`;
};
const FormItemUrl = forwardRef(function FormItemUrl({ value, defaultValue, ...props }, ref) {
const urlActionHref = useMemo(() => getUrlActionHref(value ?? defaultValue), [defaultValue, value]);
return (
<Space.Compact style={{ width: "100%" }}>
<Input ref={ref} {...props} value={value} defaultValue={defaultValue} />
{urlActionHref ? (
<Button icon={<LinkOutlined />} href={urlActionHref} target="_blank" rel="noopener noreferrer" />
) : (
<Button icon={<LinkOutlined />} disabled />
)}
</Space.Compact>
);
});
export default FormItemUrl;

View File

@@ -0,0 +1,30 @@
/**
* Normalize Form Item List Titles
* @param value
* @returns {*|string}
*/
const normalizeFormListTitleValue = (value) => {
if (value === null || value === undefined) return "";
if (Array.isArray(value)) {
return value
.map((item) => normalizeFormListTitleValue(item))
.filter(Boolean)
.join(", ");
}
return String(value).trim();
};
/**
* Get Form Listem Item Title
* @param fallbackLabel
* @param index
* @param candidates
* @returns {*|string}
*/
export function getFormListItemTitle(fallbackLabel, index, ...candidates) {
const title = candidates.map((candidate) => normalizeFormListTitleValue(candidate)).find(Boolean);
return title || `${fallbackLabel} ${index + 1}`;
}

View File

@@ -1,7 +1,7 @@
import { DownOutlined, UpOutlined } from "@ant-design/icons";
import { Space } from "antd";
export default function FormListMoveArrows({ move, index, total }) {
export default function FormListMoveArrows({ move, index, total, orientation = "vertical" }) {
const upDisabled = index === 0;
const downDisabled = index === total - 1;
@@ -14,7 +14,7 @@ export default function FormListMoveArrows({ move, index, total }) {
};
return (
<Space orientation="vertical">
<Space orientation={orientation}>
<UpOutlined disabled={upDisabled} onClick={handleUp} />
<DownOutlined disabled={downDisabled} onClick={handleDown} />
</Space>

View File

@@ -184,22 +184,29 @@ export default function GlobalSearchOs() {
return (
<AutoComplete
options={data}
onSearch={handleSearch}
defaultActiveFirstOption
onKeyDown={(e) => {
if (e.key !== "Enter") return;
const firstUrlForSearch = data?.[0]?.options?.[0]?.label?.props?.to;
if (!firstUrlForSearch) return;
navigate(firstUrlForSearch);
}}
defaultActiveFirstOption
onClear={() => setData([])}
>
<Input.Search
// className="global-search-autocomplete-fix"
size="large"
placeholder={t("general.labels.globalsearch")}
enterButton
allowClear
loading={loading}
onChange={(e) => {
const value = e.target.value;
if (!value) {
setData([]);
} else {
handleSearch(value);
}
}}
/>
</AutoComplete>
);

View File

@@ -160,9 +160,6 @@ export default function GlobalSearch() {
return (
<AutoComplete
options={options}
showSearch={{
onSearch: handleSearch
}}
defaultActiveFirstOption
onKeyDown={(e) => {
if (e.key !== "Enter") return;
@@ -172,11 +169,13 @@ export default function GlobalSearch() {
}}
>
<Input.Search
// className="global-search-autocomplete-fix"
size="large"
placeholder={t("general.labels.globalsearch")}
enterButton
allowClear
loading={loading}
onChange={(e) => handleSearch(e.target.value)}
/>
</AutoComplete>
);

View File

@@ -1,5 +1,5 @@
import { EditFilled, FileAddFilled, SyncOutlined } from "@ant-design/icons";
import { Button, Card, Input, Space, Table, Typography } from "antd";
import { Button, Card, Input, Space, Typography } from "antd";
import queryString from "query-string";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
@@ -10,6 +10,7 @@ import CurrencyFormatter from "../../utils/CurrencyFormatter";
import InventoryBillRo from "../inventory-bill-ro/inventory-bill-ro.component";
import InventoryLineDelete from "../inventory-line-delete/inventory-line-delete.component";
import { pageLimit } from "../../utils/config";
import ResponsiveTable from "../responsive-table/responsive-table.component";
const mapStateToProps = createStructuredSelector({});
const mapDispatchToProps = (dispatch) => ({
@@ -185,10 +186,11 @@ export function JobsList({ refetch, loading, jobs, total, setInventoryUpsertCont
</Space>
}
>
<Table
<ResponsiveTable
loading={loading}
pagination={{ placement: "top", pageSize: pageLimit, current: parseInt(page || 1), total: total }}
columns={columns}
mobileColumnKeys={["line_desc", "actual_price", "consumedbyjob", "actions"]}
rowKey="id"
dataSource={jobs}
onChange={handleTableChange}

View File

@@ -67,16 +67,19 @@ export function Jobd3RdPartyModal({ bodyshop, jobId, job, technician }) {
);
};
const handleInsSelect = (value, option) => {
form.setFieldsValue({
addr1: option.obj.name,
addr2: option.obj.street1,
addr3: option.obj.street2,
city: option.obj.city,
state: option.obj.state,
zip: option.obj.zip,
vendorid: null
});
const handleInsSelect = (value) => {
const selectedVendor = bodyshop.md_ins_cos.find(s => s.name === value);
if (selectedVendor) {
form.setFieldsValue({
addr1: selectedVendor.name,
addr2: selectedVendor.street1,
addr3: selectedVendor.street2,
city: selectedVendor.city,
state: selectedVendor.state,
zip: selectedVendor.zip,
vendorid: null
});
}
};
const handleVendorSelect = (vendorid) => {
@@ -103,13 +106,13 @@ export function Jobd3RdPartyModal({ bodyshop, jobId, job, technician }) {
<VendorSearchSelect options={VendorAutoCompleteData?.vendors} onSelect={handleVendorSelect} />
</Form.Item>
<Form.Item label={t("bodyshop.fields.md_ins_co.name")} name="ins_co_id">
<Select onSelect={handleInsSelect}>
{bodyshop.md_ins_cos.map((s) => (
<Select.Option key={s.name} obj={s} value={s.name}>
{s.name}
</Select.Option>
))}
</Select>
<Select
onSelect={handleInsSelect}
options={bodyshop.md_ins_cos.map((s) => ({
value: s.name,
label: s.name
}))}
/>
</Form.Item>
<LayoutFormRow grow>
<Form.Item label={t("printcenter.jobs.3rdpartyfields.addr1")} name="addr1">

View File

@@ -1,6 +1,7 @@
import { SyncOutlined } from "@ant-design/icons";
import { useQuery } from "@apollo/client/react";
import { Button, Card, Col, Row, Table, Tag } from "antd";
import { Button, Card, Checkbox, Col, Row, Space, Tag } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -11,6 +12,9 @@ import { DateTimeFormatter } from "../../utils/DateFormatter";
import BlurWrapperComponent from "../feature-wrapper/blur-wrapper.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.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({
bodyshop: selectBodyshop
@@ -22,6 +26,8 @@ export default connect(mapStateToProps, mapDispatchToProps)(JobAuditTrail);
export function JobAuditTrail({ bodyshop, jobId }) {
const { t } = useTranslation();
const notification = useNotification();
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
const { loading, data, refetch } = useQuery(QUERY_AUDIT_TRAIL, {
variables: { jobid: jobId },
skip: !jobId,
@@ -52,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 = [
{
title: t("audit.fields.created"),
@@ -163,14 +308,40 @@ export function JobAuditTrail({ bodyshop, jobId }) {
/>
}
>
<Table loading={loading} columns={columns} rowKey="id" dataSource={data ? data.audit_trail : []} />
<ResponsiveTable
loading={loading}
columns={columns}
mobileColumnKeys={["status", "created", "useremail", "operation"]}
rowKey="id"
dataSource={data ? data.audit_trail : []}
/>
</Card>
</Col>
<Col span={24}>
<Card title={t("jobs.labels.emailaudit")}>
<Table loading={loading} columns={emailColumns} rowKey="id" dataSource={data ? data.email_audit_trail : []} />
<ResponsiveTable
loading={loading}
columns={emailColumns}
mobileColumnKeys={["status", "created", "useremail", "operation"]}
rowKey="id"
dataSource={data ? data.email_audit_trail : []}
/>
</Card>
</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>
);
}

View File

@@ -1,5 +1,6 @@
import { useEffect } from "react";
import { Alert, Card, Table } from "antd";
import { Alert, Card } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { t } from "i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -68,7 +69,15 @@ export function JobCloseRGuardPpd({ job, warningCallback }) {
return (
<Card title={t("jobs.labels.ppdnotexported")}>
<Table dataSource={linesWithPPD} columns={columns} pagination={false} rowKey="id" bordered size="small" />
<ResponsiveTable
dataSource={linesWithPPD}
columns={columns}
mobileColumnKeys={["line_desc", "ppd", "act_price", "act_price_before_ppc"]}
pagination={false}
rowKey="id"
bordered
size="small"
/>
{linesWithPPD.length > 0 && (
<Alert style={{ margin: "8px 0px" }} type="warning" title={t("jobs.labels.outstanding_ppd")} />
)}

View File

@@ -1,6 +1,7 @@
import { useEffect } from "react";
import { Alert, Card, Table } from "antd";
import { Alert, Card } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { t } from "i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -62,7 +63,15 @@ export function JobCloseRGuardSublet({ job, warningCallback }) {
return (
<Card title={t("jobs.labels.subletsnotcompleted")}>
<Table dataSource={subletsNotDone} columns={columns} pagination={false} rowKey="id" bordered size="small" />
<ResponsiveTable
dataSource={subletsNotDone}
columns={columns}
mobileColumnKeys={["line_desc", "act_price", "part_qty", "notes"]}
pagination={false}
rowKey="id"
bordered
size="small"
/>
{subletsNotDone.length > 0 && (
<Alert style={{ margin: "8px 0px" }} type="warning" title={t("jobs.labels.outstanding_sublets")} />
)}

View File

@@ -1,4 +1,5 @@
import { Input, Space, Table, Typography } from "antd";
import { Input, Space, Typography } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { alphaSort } from "../../utils/sorters";
@@ -65,7 +66,7 @@ export default function JobCostingPartsTable({ data, summaryData }) {
return (
<div>
<Table
<ResponsiveTable
title={() => {
return (
<Space wrap>
@@ -87,18 +88,19 @@ export default function JobCostingPartsTable({ data, summaryData }) {
onChange={handleTableChange}
pagination={{ placement: "top", defaultPageSize: pageLimit }}
columns={columns}
mobileColumnKeys={["cost_center", "sales", "costs", "gpdollars", "gppercent"]}
rowKey="id"
dataSource={filteredData}
summary={() => (
<Table.Summary.Row>
<Table.Summary.Cell>
<ResponsiveTable.Summary.Row>
<ResponsiveTable.Summary.Cell>
<Typography.Title level={4}>{t("general.labels.totals")}</Typography.Title>
</Table.Summary.Cell>
<Table.Summary.Cell>{Dinero(summaryData.totalSales).toFormat()}</Table.Summary.Cell>
<Table.Summary.Cell>{Dinero(summaryData.totalCost).toFormat()}</Table.Summary.Cell>
<Table.Summary.Cell>{Dinero(summaryData.gpdollars).toFormat()}</Table.Summary.Cell>
<Table.Summary.Cell></Table.Summary.Cell>
</Table.Summary.Row>
</ResponsiveTable.Summary.Cell>
<ResponsiveTable.Summary.Cell>{Dinero(summaryData.totalSales).toFormat()}</ResponsiveTable.Summary.Cell>
<ResponsiveTable.Summary.Cell>{Dinero(summaryData.totalCost).toFormat()}</ResponsiveTable.Summary.Cell>
<ResponsiveTable.Summary.Cell>{Dinero(summaryData.gpdollars).toFormat()}</ResponsiveTable.Summary.Cell>
<ResponsiveTable.Summary.Cell></ResponsiveTable.Summary.Cell>
</ResponsiveTable.Summary.Row>
)}
/>
</div>

View File

@@ -58,10 +58,8 @@ const span = {
export function JobDetailCards({ bodyshop, setPrintCenterContext, insertAuditTrail }) {
const { scenarioNotificationsOn } = useSocket();
const [updateJob] = useMutation(UPDATE_JOB);
const selectedBreakpoint = Object.entries(Grid.useBreakpoint())
.filter((screen) => !!screen[1])
.slice(-1)[0];
const screens = Grid.useBreakpoint();
const bpoints = {
xs: "100%",
sm: "100%",
@@ -70,7 +68,14 @@ export function JobDetailCards({ bodyshop, setPrintCenterContext, insertAuditTra
xl: "75%",
xxl: "75%"
};
const drawerPercentage = selectedBreakpoint ? bpoints[selectedBreakpoint[0]] : "100%";
let drawerPercentage = "100%";
if (screens.xxl) drawerPercentage = bpoints.xxl;
else if (screens.xl) drawerPercentage = bpoints.xl;
else if (screens.lg) drawerPercentage = bpoints.lg;
else if (screens.md) drawerPercentage = bpoints.md;
else if (screens.sm) drawerPercentage = bpoints.sm;
else if (screens.xs) drawerPercentage = bpoints.xs;
const searchParams = queryString.parse(useLocation().search);
const { selected } = searchParams;

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