Compare commits

..

144 Commits

Author SHA1 Message Date
Allan Carr
b08435607e IO-3529 CM Recieved Fix
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-31 17:43:16 -08:00
Allan Carr
ea9e4ffcad IO-3529 Fix for Parts Return
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-31 16:59:17 -08:00
Allan Carr
dadc9892d0 IO-3529 Job Lines on Closing add IDs
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-31 11:32:05 -08:00
Allan Carr
bf5a099fa6 IO-3529 DMS Make Code
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-30 22:39:56 -08:00
Dave Richer
27b955a701 Merged in release/2026-01-23 (pull request #2918)
Release/2026 01 23 into master-AIO - IO-3497, IO-3499, IO-3503, IO-3509, IO-3512, IO-3514, IO-3523
2026-01-31 03:23:30 +00:00
Allan Carr
1896c4db59 Merged in feature/IO-3503-Job-Costing-Fixes (pull request #2921)
IO-3503 Job Costing Corrections

Approved-by: Dave Richer
2026-01-31 01:10:20 +00:00
Allan Carr
78770ed54e IO-3503 Job Costing Corrections
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-30 16:45:25 -08:00
Dave Richer
9e2ae2cc10 Merged in feature/IO-3499-React-19 (pull request #2919)
feature/IO-3499-React-19 -Checkpoint
2026-01-30 22:32:54 +00:00
Dave
3a0f6101c8 feature/IO-3499-React-19 -Checkpoint 2026-01-30 17:32:12 -05:00
Dave Richer
f0dfa2717f Merged in feature/IO-3499-React-19 (pull request #2916)
feature/IO-3499-React-19 -Checkpoint
2026-01-30 17:33:51 +00:00
Dave
1f3be72d9d feature/IO-3499-React-19 -Checkpoint 2026-01-30 12:33:20 -05:00
Allan Carr
3d9ad799f3 Merged in hotfix/2026-01-29 (pull request #2915)
IO-3522 Replace Email with Phone1
2026-01-29 21:31:13 +00:00
Allan Carr
6e17ef10bb Merged in feature/IO-3522-Fortellis-Bug-Fix (pull request #2914)
IO-3522 Replace Email with Phone1
2026-01-29 21:28:48 +00:00
Allan Carr
fdc06e79a6 IO-3522 Replace Email with Phone1
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-29 13:29:01 -08:00
Allan Carr
66924367fc Merged in feature/IO-3522-Fortellis-Bug-Fix (pull request #2913)
IO-3522 Replace Email with Phone1
2026-01-29 21:28:11 +00:00
Allan Carr
f76165552e Merged in hotfix/2026-01-29 (pull request #2912)
IO-3522 Fortellis Null Coalesce for Owner data
2026-01-29 21:06:39 +00:00
Allan Carr
80fbb847d8 Merged in feature/IO-3522-Fortellis-Bug-Fix (pull request #2911)
IO-3522 Fortellis Null Coalesce for Owner data
2026-01-29 21:02:40 +00:00
Allan Carr
ca1703e724 Merged in feature/IO-3522-Fortellis-Bug-Fix (pull request #2910)
Feature/IO-3522 Fortellis Bug Fix

Approved-by: Patrick Fic
2026-01-29 21:00:45 +00:00
Allan Carr
163819809c IO-3522 Fortellis Null Coalesce for Owner data
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-29 12:57:14 -08:00
Dave Richer
42fa85e145 Merged in feature/IO-3499-React-19 (pull request #2908)
feature/IO-3499-React-19 -Checkpoint
2026-01-29 17:37:01 +00:00
Dave
13104f36e3 feature/IO-3499-React-19 -Checkpoint 2026-01-29 12:36:34 -05:00
Dave Richer
0c9f7df9ac Merged in feature/IO-3499-React-19 (pull request #2907)
feature/IO-3499-React-19 -Checkpoint
2026-01-29 17:33:26 +00:00
Dave
a9280a83ba feature/IO-3499-React-19 -Checkpoint 2026-01-29 12:31:04 -05:00
Dave Richer
78d816fa8b Merged in feature/IO-3499-React-19 (pull request #2905)
feature/IO-3499-React-19 -Checkpoint
2026-01-28 19:02:42 +00:00
Dave
9f573fc5b4 feature/IO-3499-React-19 -Checkpoint 2026-01-28 14:02:06 -05:00
Dave Richer
4a1b1fe905 Merged in feature/IO-3499-React-19 (pull request #2902)
Feature/IO-3499 React 19
2026-01-28 02:31:43 +00:00
Dave
5f81ec2099 feature/IO-3499-React-19 -Checkpoint 2026-01-27 21:31:03 -05:00
Dave
147977be58 feature/IO-3499-React-19 -Checkpoint 2026-01-27 20:57:16 -05:00
Dave
4dfda4b371 feature/IO-3499-React-19 -Checkpoint 2026-01-27 20:40:04 -05:00
Dave
02feba2804 feature/IO-3499-React-19 -Checkpoint 2026-01-27 19:54:40 -05:00
Dave Richer
a9fb77189e Merged in feature/IO-3499-React-19 (pull request #2900)
Feature/IO-3499 React 19
2026-01-28 00:27:51 +00:00
Dave
3bacad69e3 feature/IO-3499-React-19 -Checkpoint 2026-01-27 19:26:42 -05:00
Patrick Fic
70b6aa63ed Merged in feature/IO-3517-fortellis-hotfix (pull request #2899)
IO-3517 Resolve emit on fortellis completion.
2026-01-27 19:32:02 +00:00
Patrick Fic
844a879f1c IO-3517 Resolve emit on fortellis completion. 2026-01-27 11:31:10 -08:00
Dave
6415b302dc Merge remote-tracking branch 'origin/release/2026-01-23' into feature/IO-3499-React-19 2026-01-27 10:29:54 -05:00
Allan Carr
d40dd649e2 Merged in feature/IO-3512-Page-Titles (pull request #2896)
IO-3512 Great Search Fix

Approved-by: Dave Richer
2026-01-27 15:28:01 +00:00
Allan Carr
35366eda22 IO-3512 Great Search Fix
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-27 07:27:43 -08:00
Dave
9a53896aa4 feature/IO-3499-React-19 -Add pointer icons to clickable things in the production list view 2026-01-26 15:04:03 -05:00
Dave Richer
278765d019 Merged in feature/IO-3499-React-19 (pull request #2894)
feature/IO-3499-React-19 -Checkpoint
2026-01-26 19:06:47 +00:00
Dave
6fd5fc8f66 Merge remote-tracking branch 'origin/release/2026-01-23' into feature/IO-3499-React-19 2026-01-26 14:04:10 -05:00
Dave
346a6e69c7 feature/IO-3499-React-19 -Checkpoint 2026-01-26 14:02:31 -05:00
Allan Carr
5d5fa8fead Merged in feature/IO-3514-Tech-Console-Fixes (pull request #2890)
IO-3514 Print Center Restrict Financial Group on Tech Station and Fix Drawer Close on Tech Console

Approved-by: Dave Richer
2026-01-26 16:32:33 +00:00
Allan Carr
30dae4e365 Merged in feature/IO-3509-Duplicate-Job-Open-Estimate-Date (pull request #2891)
IO-3509 Duplicate Job Open Estimate Date

Approved-by: Dave Richer
2026-01-26 14:56:24 +00:00
Allan Carr
f6899f744b Merged in feature/IO-3512-Page-Titles (pull request #2889)
IO-3512 Page Title

Approved-by: Dave Richer
2026-01-26 14:54:58 +00:00
Allan Carr
cca23a5b11 IO-3509 Correction for date_estimate to handle tz correctly
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-23 23:07:11 -08:00
Allan Carr
2acddcb9ac IO-3509 Duplicate Job Open Estimate Date
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-23 22:51:53 -08:00
Allan Carr
4efa01edd3 IO-3514 Print Center Restrict Financial Group on Tech Station and Fix Drawer Close on Tech Console
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-23 22:26:22 -08:00
Allan Carr
ff57592c12 IO-3512 Page Title
Fix Search UI to be consistent across product

Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-23 20:02:19 -08:00
Dave Richer
6643e92665 Merged in feature/IO-3499-React-19 (pull request #2887)
feature/IO-3499-React-19 - The great button refactor of 2026
2026-01-24 02:43:52 +00:00
Dave
2e3452bc61 feature/IO-3499-React-19 - The great button refactor of 2026 2026-01-23 21:43:33 -05:00
Dave Richer
b394b85923 Merged in feature/IO-3499-React-19 (pull request #2885)
feature/IO-3499-React-19 - The great button refactor of 2026
2026-01-24 02:18:59 +00:00
Dave
94a5b4901b feature/IO-3499-React-19 - The great button refactor of 2026 2026-01-23 21:17:20 -05:00
Dave Richer
f6637dcae8 Merged in feature/IO-3499-React-19 (pull request #2883)
Feature/IO-3499 React 19
2026-01-24 01:48:11 +00:00
Dave
9a93a43642 feature/IO-3499-React-19 - The great button refactor of 2026 2026-01-23 20:37:16 -05:00
Dave
9475dfb4e8 feature/IO-3499-React-19 - Checkpoint 2026-01-23 19:24:14 -05:00
Dave
fe0ddc5824 feature/IO-3499-React-19 - Checkpoint 2026-01-23 19:19:54 -05:00
Dave
14365d45d2 feature/IO-3499-React-19 - Checkpoint 2026-01-23 19:12:31 -05:00
Dave
587a3104db feature/IO-3499-React-19 - Checkpoint 2026-01-23 18:36:00 -05:00
Dave
745ec57510 feature/IO-3499-React-19 - Checkpoint 2026-01-23 18:12:01 -05:00
Allan Carr
5ad13e1060 Merged in feature/IO-3503-Job-Costing-Fixes (pull request #2881)
IO-3503 Job Costing Fixes for CAD
2026-01-23 22:17:09 +00:00
Allan Carr
e1666baddd IO-3503 Job Costing Fixes for CAD
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-23 14:17:03 -08:00
Dave
7f43ba33f6 feature/IO-3499-React-19 - Phone Number Formatter / Chat Open Button / Chat Affix container 2026-01-23 15:50:38 -05:00
Allan Carr
5c95b9fc5a Merged in feature/IO-3503-Job-Costing-Fixes (pull request #2875)
IO-3503 Job Costing Fixes

Approved-by: Dave Richer
2026-01-23 19:29:24 +00:00
Dave Richer
2faeca3069 Merged in feature/IO-3499-React-19 (pull request #2878)
feature/IO-3499-React-19 - The Calculate button on Totals did not actually have refetch passed down
2026-01-23 18:04:06 +00:00
Dave
53cb1d2f65 feature/IO-3499-React-19 - The Calculate button on Totals did not actually have refetch passed down 2026-01-23 12:54:20 -05:00
Dave Richer
360421b254 Merged in feature/IO-3499-React-19 (pull request #2876)
Feature/IO-3499 React 19
2026-01-23 17:43:07 +00:00
Dave
3918f3d72b feature/IO-3499-React-19 Checkpoint 2026-01-23 12:42:02 -05:00
Allan Carr
c29ac5f711 IO-3503 Job Costing Fixes
Correction to handle CAD

Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-22 15:52:20 -08:00
Dave
40e8529eeb feature/IO-3499-React-19 Production Table on schedule was missing col headers 2026-01-22 13:48:43 -05:00
Dave Richer
d0148f48a8 Merged in feature/IO-3499-React-19 (pull request #2873)
feature/IO-3499-React-19 Checkpoint
2026-01-22 16:54:20 +00:00
Dave
48336b88e0 feature/IO-3499-React-19 Checkpoint 2026-01-22 11:53:29 -05:00
Dave Richer
415f256f07 Merged in feature/IO-3499-React-19 (pull request #2871)
feature/IO-3499-React-19 Checkpoint
2026-01-22 01:30:50 +00:00
Dave
7fe9098f69 feature/IO-3499-React-19 Checkpoint 2026-01-21 20:29:55 -05:00
Dave Richer
e598ee69e6 Merged in feature/IO-3499-React-19 (pull request #2869)
feature/IO-3499-React-19 Checkpoint
2026-01-22 01:19:17 +00:00
Dave
c7df7a7d47 feature/IO-3499-React-19 Checkpoint 2026-01-21 20:19:01 -05:00
Dave
9b81cb7314 feature/IO-3499-React-19 Checkpoint 2026-01-21 20:17:31 -05:00
Dave Richer
83439ecb15 Merged in feature/IO-3499-React-19 (pull request #2868)
feature/IO-3499-React-19 Checkpoint
2026-01-22 00:35:04 +00:00
Dave
fabf2fb8dd feature/IO-3499-React-19 Checkpoint 2026-01-21 19:33:44 -05:00
Dave Richer
d92ca15056 Merged in feature/IO-3499-React-19 (pull request #2866)
feature/IO-3499-React-19 Checkpoint
2026-01-21 22:56:24 +00:00
Dave
49bfb0849d feature/IO-3499-React-19 Checkpoint 2026-01-21 17:55:57 -05:00
Dave Richer
538dcce78a Merged in feature/IO-3499-React-19 (pull request #2864)
feature/IO-3499-React-19 Checkpoint
2026-01-21 22:28:38 +00:00
Dave
f5a618319a feature/IO-3499-React-19 Checkpoint 2026-01-21 17:27:21 -05:00
Dave Richer
151598c563 Merged in feature/IO-3499-React-19 (pull request #2863)
feature/IO-3499-React-19 Checkpoint
2026-01-21 19:06:40 +00:00
Dave
d06b20b1a8 feature/IO-3499-React-19 Checkpoint 2026-01-21 14:05:03 -05:00
Dave Richer
407c6456ae Merged in feature/IO-3499-React-19 (pull request #2861)
feature/IO-3499-React-19 Checkpoint
2026-01-21 18:37:11 +00:00
Dave
803a811039 feature/IO-3499-React-19 Checkpoint 2026-01-21 13:35:51 -05:00
Dave
645b20bf8a Merge remote-tracking branch 'origin/feature/IO-3503-Job-Costing-Fixes' into release/2026-01-23 2026-01-21 12:28:42 -05:00
Dave Richer
32541a82e3 Merged in feature/IO-3499-React-19 (pull request #2858)
feature/IO-3499-React-19 checkpoint
2026-01-21 16:55:07 +00:00
Dave
ee7892974f feature/IO-3499-React-19 checkpoint 2026-01-21 11:54:06 -05:00
Dave Richer
a0857c3865 Merged in feature/IO-3499-React-19 (pull request #2856)
Feature/IO-3499 React 19
2026-01-20 22:41:35 +00:00
Dave
7c3db5c7bd feature/IO-3499-React-19 checkpoint 2026-01-20 17:39:12 -05:00
Dave
8de507bf37 feature/IO-3499-React-19 checkpoint 2026-01-20 17:22:07 -05:00
Dave
4c8783a2c2 feature/IO-3499-React-19 checkpoint 2026-01-20 16:25:36 -05:00
Dave Richer
976b3aa7d4 Merged in feature/IO-3499-React-19 (pull request #2854)
Feature/IO-3499 React 19
2026-01-20 20:38:04 +00:00
Dave
d7e3b52dc6 feature/IO-3499-React-19 checkpoint 2026-01-20 15:27:32 -05:00
Dave
a91bfea581 feature/IO-3499-React-19 checkpoint 2026-01-20 15:01:38 -05:00
Allan Carr
1716c3e6b2 IO-3503 Job Costing Fixes
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-20 11:55:12 -08:00
Dave
bc78bbd5fa feature/IO-3499-React-19 checkpoint 2026-01-20 13:29:15 -05:00
Dave Richer
44533e9777 Merged in feature/IO-3499-React-19 (pull request #2851)
Feature/IO-3499 React 19
2026-01-20 16:39:09 +00:00
Dave
a22c0298d1 feature/IO-3499-React-19 checkpoint 2026-01-20 11:37:10 -05:00
Dave
423077b79c feature/IO-3499-React-19 checkpoint 2026-01-20 09:58:37 -05:00
Dave
640e0987ad feature/IO-3499-React-19 checkpoint 2026-01-20 09:43:18 -05:00
Dave
c3e12cfeff feature/IO-3499-React-19: Cursor style adjustments for Employee Assignments 2026-01-19 19:13:35 -05:00
Dave Richer
fb9c294dd8 Merged in feature/IO-3499-React-19 (pull request #2849)
Feature/IO-3499 React 19
2026-01-19 19:32:12 +00:00
Dave
89622f0af2 feature/IO-3499-React-19: Manual Appointment in Schedule, Email Form console error 2026-01-19 14:30:11 -05:00
Dave
7a0187afbe feature/IO-3499-React-19: Production Note + Comment edit in job details header / card 2026-01-19 14:18:51 -05:00
Allan Carr
f3513a80c5 Merged in hotfix/2026-01-15 (pull request #2848)
IO-3498 No SalesTerms for Credits
2026-01-19 18:57:06 +00:00
Allan Carr
ac1dcf4604 Merged in feature/IO-3498-QBO-Auth-Token (pull request #2846)
IO-3498 No SalesTerms for Credits

Approved-by: Dave Richer
2026-01-19 18:54:28 +00:00
Allan Carr
3c38d9daeb Merged in feature/IO-3498-QBO-Auth-Token (pull request #2847)
IO-3498 No SalesTerms for Credits

Approved-by: Dave Richer
2026-01-19 18:54:15 +00:00
Allan Carr
a9a49009ba IO-3498 No SalesTerms for Credits
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-19 10:34:32 -08:00
Dave
8bd7e5cc6d feature/IO-3499-React-19: Email breaking bounds in job info / responsive.. 2026-01-19 13:17:39 -05:00
Dave Richer
27b9c3f342 Merged in hotfix/2026-01-15 (pull request #2844)
Hotfix/2026 01 15

Approved-by: Allan Carr
2026-01-17 01:48:21 +00:00
Allan Carr
334077a39d IO-3498 Remove setNewRefreshToken and replace forEach with map
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-16 17:07:27 -08:00
Allan Carr
23ce1c42d1 Merged in feature/IO-3498-QBO-Auth-Token (pull request #2843)
IO-3498 QBO Fix for Returned Data from oauthClient

Approved-by: Dave Richer
2026-01-17 01:07:25 +00:00
Allan Carr
8ede23d55a Merged in feature/IO-3498-QBO-Auth-Token (pull request #2842)
IO-3498 QBO Fix for Returned Data from oauthClient

Approved-by: Dave Richer
2026-01-17 01:07:04 +00:00
Allan Carr
6a521c0f46 IO-3498 QBO Fix for Returned Data from oauthClient
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-16 16:37:29 -08:00
Dave Richer
fe8200dadd Merged in feature/IO-3499-React-19 (pull request #2840)
Feature/IO-3499 React 19
2026-01-16 22:16:02 +00:00
Dave
ddf4256e58 feature/IO-3499-React-19: Ticket Ticket Issues, Employee Select Issues 2026-01-16 17:13:43 -05:00
Dave
5271970ec1 feature/IO-3499-React-19: Ticket Ticket Issues, Employee Select Issues 2026-01-16 16:41:56 -05:00
Dave Richer
6f8b91d9d0 Merged in feature/IO-3499-React-19 (pull request #2838)
Feature/IO-3499 React 19
2026-01-16 19:28:36 +00:00
Dave
a2230be5fe feature/IO-3499-React-19: Fix issue skylar found 2026-01-16 14:26:50 -05:00
Dave
7f0f5c2aa3 feature/IO-3499-React-19: Fix issue skylar found 2026-01-16 14:23:04 -05:00
Dave Richer
a97a9c8d28 Merged in feature/IO-3499-React-19 (pull request #2836)
feature/IO-3499-React-19: Some more missing <Cards> and a missing message -> title
2026-01-16 18:28:36 +00:00
Dave
4896746600 feature/IO-3499-React-19: Some more missing <Cards> and a missing message -> title 2026-01-16 13:27:50 -05:00
Dave Richer
f2eb4abfca Merged in feature/IO-3499-React-19 (pull request #2834)
feature/IO-3499-React-19: Fix bill edit
2026-01-16 17:29:37 +00:00
Dave
480ee27b80 feature/IO-3499-React-19: Fix bill edit 2026-01-16 12:28:53 -05:00
Dave
e46d819979 feature/IO-3499-React-19-ProductionBoard - Add misisng ENV in rome side (client) 2026-01-16 12:09:36 -05:00
Dave Richer
55dd0c6e14 Merged in hotfix/2026-01-15 (pull request #2833)
IO-3498 QBO Changes due to oauthClient response change

Approved-by: Allan Carr
2026-01-16 00:55:49 +00:00
Dave
d30e03a184 Merge remote-tracking branch 'origin/feature/IO-3498-QBO-Auth-Token' into hotfix/2026-01-15 2026-01-15 19:35:44 -05:00
Allan Carr
f89112902c Merged in feature/IO-3498-QBO-Auth-Token (pull request #2831)
IO-3498 QBO Changes due to oauthClient response change

Approved-by: Dave Richer
2026-01-16 00:30:39 +00:00
Allan Carr
f40af8cba4 IO-3498 QBO Changes due to oauthClient response change
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-15 16:10:42 -08:00
Dave Richer
75a8669034 Merged in feature/IO-3499-React-19-ProductionBoard (pull request #2829)
Feature/IO-3499 React 19 ProductionBoard
2026-01-15 19:33:35 +00:00
Allan Carr
aef04ec29e Merged in hotfix/2026-01-15 (pull request #2828)
Hotfix/2026 01 15
2026-01-15 16:57:04 +00:00
Dave Richer
e23c5a654b Merged in feature/IO-3499-React-19-ProductionBoard (pull request #2826)
Feature/IO-3499 React 19 ProductionBoard
2026-01-15 16:41:37 +00:00
Allan Carr
69e57195d3 Merged in feature/IO-3503-Job-Costing-Bug-Fix (pull request #2824)
IO-3503 Job Costing Bug Fix

Approved-by: Dave Richer
2026-01-15 16:40:47 +00:00
Allan Carr
ad99cd4c18 Merged in feature/IO-3503-Job-Costing-Bug-Fix (pull request #2823)
IO-3503 Job Costing Bug Fix

Approved-by: Dave Richer
2026-01-15 16:40:17 +00:00
Allan Carr
883c7257db Merged in feature/IO-3500-PROFIT-CENTER-ITEM (pull request #2825)
IO-3500 Profit Center Item

Approved-by: Dave Richer
2026-01-15 16:39:02 +00:00
Allan Carr
92fd5b0315 IO-3503 Job Costing Bug Fix
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-14 18:02:26 -08:00
Dave Richer
d52f12f16d Merged in feature/IO-3499-React-19 (pull request #2821)
Feature/IO-3499 React 19
2026-01-14 16:58:13 +00:00
Dave Richer
183774d7cd Merged in feature/IO-3499-React-19 (pull request #2818)
feature/IO-3499-React-19: Bug Fixes / Checkpoint
2026-01-14 03:32:29 +00:00
Allan Carr
2fee2ae264 Merged in feature/IO-3500-PROFIT-CENTER-ITEM (pull request #2816)
IO-3500 Profit Center Item

Approved-by: Dave Richer
2026-01-14 00:41:21 +00:00
Allan Carr
54ce0e1802 IO-3500 Profit Center Item
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-13 16:40:24 -08:00
Dave Richer
eadbf3237d Merged in feature/IO-3499-React-19 (pull request #2814)
Feature/IO-3499 React 19
2026-01-13 22:18:37 +00:00
Dave Richer
be2df79555 Merged in hotfix/2026-01-13 (pull request #2813)
Hotfix/2026 01 13 into master-AIO
2026-01-13 20:52:00 +00:00
209 changed files with 5527 additions and 3789 deletions

View File

@@ -0,0 +1,593 @@
# React 19 & Ant Design 6 Upgrade - Deprecation Fixes Report
## Overview
This document outlines all deprecations fixed during the upgrade from React 18 to React 19 and Ant Design 5 to Ant Design 6 in the branch `feature/IO-3499-React-19` compared to `origin/master-AIO`.
---
## 1. Core Dependency Updates
### React & React DOM
- **Upgraded from:** React ^18.3.1 → React ^19.2.4
- **Upgraded from:** React DOM ^18.3.1 → React DOM ^19.2.4
- **Impact:** Enabled React 19 compiler optimizations and new concurrent features
### Ant Design
- **Upgraded from:** Ant Design ^5.28.1 → ^6.2.2
- **Upgraded from:** @ant-design/icons ^5.6.1 → ^6.1.0
- **Impact:** Access to Ant Design 6 improvements and API changes
### Apollo GraphQL
- **@apollo/client:** ^3.13.9 → ^4.1.3
- **apollo-link-logger:** ^2.0.1 → ^3.0.0
- **graphql-ws:** ^6.0.7 (added for WebSocket subscriptions)
- **Impact:** Major version upgrade with breaking changes to import paths and API
### React Ecosystem Libraries
- **react-router-dom:** ^6.30.0 → ^7.13.0
- **react-i18next:** ^15.7.3 → ^16.5.4
- **react-grid-layout:** 1.3.4 → ^2.2.2
- **@testing-library/react:** ^16.3.1 → ^16.3.2
- **styled-components:** ^6.2.0 → ^6.3.8
### Build Tools
- **Vite:** ^7.3.1 (maintained, peer dependencies updated)
- **vite-plugin-babel:** ^1.3.2 → ^1.4.1
- **vite-plugin-node-polyfills:** ^0.24.0 → ^0.25.0
- **vitest:** ^3.2.4 → ^4.0.18
### Monitoring & Analytics
- **@sentry/react:** ^9.43.0 → ^10.38.0
- **@sentry/cli:** ^2.58.2 → ^3.1.0
- **@sentry/vite-plugin:** ^4.6.1 → ^4.8.0
- **logrocket:** ^9.0.2 → ^12.0.0
- **posthog-js:** ^1.315.1 → ^1.336.4
- **@amplitude/analytics-browser:** ^2.33.1 → ^2.34.0
### Other Key Dependencies
- **axios:** ^1.13.2 → ^1.13.4
- **env-cmd:** ^10.1.0 → ^11.0.0
- **i18next:** ^25.7.4 → ^25.8.0
- **libphonenumber-js:** ^1.12.33 → ^1.12.36
- **lightningcss:** ^1.30.2 → ^1.31.1
- **@fingerprintjs/fingerprintjs:** ^4.6.1 → ^5.0.1
- **@firebase/app:** ^0.14.6 → ^0.14.7
- **@firebase/firestore:** ^4.9.3 → ^4.10.0
### Infrastructure
- **Node.js:** 22.x → 24.x (Dockerfile updated)
---
## 2. React 19 Compiler Optimizations
### Manual Memoization Removed
React 19's new compiler automatically optimizes components, making manual memoization unnecessary and potentially counterproductive.
#### 2.1 `useMemo` Hook Removals
**Example - Job Watchers:**
```javascript
// BEFORE
const jobWatchers = useMemo(() => (watcherData?.job_watchers ? [...watcherData.job_watchers] : []), [watcherData]);
// AFTER
// Do NOT clone arrays; keep referential stability for React Compiler and to reduce rerenders.
const jobWatchers = watcherData?.job_watchers ?? EMPTY_ARRAY;
```
**Benefits:**
- Eliminates unnecessary array cloning
- Maintains referential stability for React Compiler
- Reduces re-renders
- Cleaner, more readable code
**Files Affected:**
- Multiple kanban components
- Production board components
- Job management components
#### 2.2 `useCallback` Hook Removals
**Example - Card Lookup Function:**
```javascript
// BEFORE
const getCardByID = useCallback((data, cardId) => {
for (const lane of data.lanes) {
for (const card of lane.cards) {
// ... logic
}
}
}, [/* dependencies */]);
// AFTER
const getCardByID = (data, cardId) => {
for (const lane of data.lanes) {
for (const card of lane.cards) {
// ... logic
}
}
};
```
**Benefits:**
- React 19 compiler automatically optimizes function references
- Reduced complexity in component code
- No need to manage dependency arrays
**Files Affected:**
- production-board-kanban.component.jsx
- production-board-kanban.container.jsx
- Multiple board controller components
#### 2.3 `React.memo()` Wrapper Removals
**Example - EllipsesToolTip Component:**
```javascript
// BEFORE
const EllipsesToolTip = memo(({ title, children, kiosk }) => {
if (kiosk || !title) {
return <div className="ellipses no-select">{children}</div>;
}
return (
<Tooltip title={title}>
<div className="ellipses no-select">{children}</div>
</Tooltip>
);
});
EllipsesToolTip.displayName = "EllipsesToolTip";
// AFTER
function EllipsesToolTip({ title, children, kiosk }) {
if (kiosk || !title) {
return <div className="ellipses no-select">{children}</div>;
}
return (
<Tooltip title={title}>
<div className="ellipses no-select">{children}</div>
</Tooltip>
);
}
```
**Benefits:**
- Compiler handles optimization automatically
- No need for manual displayName assignment
- Standard function syntax is cleaner
**Files Affected:**
- production-board-kanban-card.component.jsx
- EllipsesToolTip components
- Various utility components
---
## 3. State Management Optimizations
### Deep Cloning Elimination
React 19's compiler efficiently handles change detection, eliminating the need for manual deep cloning.
**Example - Board Lanes State Update:**
```javascript
// BEFORE
setBoardLanes((prevBoardLanes) => {
const deepClonedData = cloneDeep(newBoardData);
if (!isEqual(prevBoardLanes, deepClonedData)) {
return deepClonedData;
}
return prevBoardLanes;
});
// AFTER
setBoardLanes(newBoardData);
```
**Benefits:**
- Removed lodash dependencies (`cloneDeep`, `isEqual`) from components
- Reduced memory overhead
- Faster state updates
- React 19's compiler handles change detection efficiently
---
## 4. Import Cleanup
### React Import Simplifications
**Example - Removed Unnecessary Hook Imports:**
```javascript
// BEFORE
import { useMemo, useState, useEffect, useCallback } from "react";
// AFTER
import { useState, useEffect } from "react";
```
Multiple files had their React imports streamlined by removing `useMemo`, `useCallback`, and `memo` imports that are no longer needed.
---
## 5. Apollo Client 4.x Migration
### Import Path Changes
Apollo Client 4.x requires React-specific imports to come from `@apollo/client/react` instead of the main package.
**Example - Hook Imports:**
```javascript
// BEFORE (Apollo Client 3.x)
import { useQuery, useMutation, useLazyQuery } from "@apollo/client";
import { ApolloProvider } from "@apollo/client";
import { useApolloClient } from "@apollo/client";
// AFTER (Apollo Client 4.x)
import { useQuery, useMutation, useLazyQuery } from "@apollo/client/react";
import { ApolloProvider } from "@apollo/client/react";
import { useApolloClient } from "@apollo/client/react";
```
**Benefits:**
- Better tree-shaking for non-React Apollo Client usage
- Clearer separation between core and React-specific functionality
- Reduced bundle size for React-only applications
**Files Affected:**
- All components using Apollo hooks (50+ files)
- Main app provider component
- GraphQL container components
### `useLazyQuery` API Changes
The return value destructuring pattern for `useLazyQuery` changed in Apollo Client 4.x.
**Example - Query Function Extraction:**
```javascript
// BEFORE (Apollo Client 3.x)
const [, { data, refetch, queryLoading }] = useLazyQuery(QUERY_RO_AND_OWNER_BY_JOB_PKS, {
variables: { jobids: [context.jobid] },
skip: !context?.jobid
});
// AFTER (Apollo Client 4.x)
const [loadRoAndOwnerByJobPks, { data, loading: queryLoading, error: queryError, refetch, called }] = useLazyQuery(
QUERY_RO_AND_OWNER_BY_JOB_PKS
);
// Call the query function explicitly when needed
useEffect(() => {
if (context?.jobid) {
loadRoAndOwnerByJobPks({ variables: { jobids: [context.jobid] } });
}
}, [context?.jobid, loadRoAndOwnerByJobPks]);
```
**Key Changes:**
- **Query function must be destructured**: Previously ignored with `,` now must be named
- **Options moved to function call**: `variables` and other options passed when calling the query function
- **`loading` renamed**: More consistent with `useQuery` hook naming
- **`called` property added**: Track if the query has been executed at least once
- **No more `skip` option**: Logic moved to conditional query execution
**Benefits:**
- More explicit control over when queries execute
- Better alignment with `useQuery` API patterns
- Clearer code showing query execution timing
**Files Affected:**
- card-payment-modal.component.jsx
- bill-form.container.jsx
- Multiple job and payment components
---
## 6. forwardRef Pattern Migration
React 19 simplifies ref handling by allowing `ref` to be passed as a regular prop, eliminating the need for `forwardRef` in most cases.
### forwardRef Wrapper Removal
**Example - Component Signature Change:**
```javascript
// BEFORE
import { forwardRef } from "react";
const BillLineSearchSelect = ({ options, disabled, allowRemoved, ...restProps }, ref) => {
const { t } = useTranslation();
return (
<Select
ref={ref}
options={generateOptions(options, allowRemoved, t)}
disabled={disabled}
{...restProps}
/>
);
};
export default forwardRef(BillLineSearchSelect);
// AFTER
const BillLineSearchSelect = ({ options, disabled, allowRemoved, ref, ...restProps }) => {
const { t } = useTranslation();
return (
<Select
ref={ref}
options={generateOptions(options, allowRemoved, t)}
disabled={disabled}
{...restProps}
/>
);
};
export default BillLineSearchSelect;
```
**Key Changes:**
- **`ref` as regular prop**: Moved from second parameter to first parameter as a regular prop
- **No `forwardRef` import needed**: Removed from React imports
- **No `forwardRef` wrapper**: Export component directly
- **Same ref behavior**: Works identically from parent component perspective
**Benefits:**
- Simpler component API (single parameter instead of two)
- Reduced boilerplate code
- Better TypeScript inference
- More intuitive for developers
**Components Migrated:**
- BillLineSearchSelect
- ContractStatusComponent
- CourtesyCarFuelComponent
- CourtesyCarReadinessComponent
- CourtesyCarStatusComponent
- EmployeeTeamSearchSelect
- FormInputNumberCalculator
- FormItemCurrency
- FormItemEmail
- 10+ additional form components
---
## 7. React.lazy Import Cleanup
React 19 makes `React.lazy` usage more seamless, and in some cases lazy imports were removed where they were no longer beneficial.
**Example - Lazy Import Removal:**
```javascript
// BEFORE
import { lazy, Suspense, useEffect, useRef, useState } from "react";
const LazyComponent = lazy(() => import('./HeavyComponent'));
// AFTER
import { Suspense, useEffect, useRef, useState } from "react";
// Lazy loading handled differently or component loaded directly
```
**Context:**
- Some components had lazy imports removed where the loading behavior wasn't needed
- `Suspense` boundaries maintained for actual lazy-loaded components
- React 19 improves Suspense integration
**Files Affected:**
- Multiple route components
- Dashboard components
- Heavy data visualization components
---
## 8. StrictMode Integration
React 19's StrictMode was explicitly added to help catch potential issues during development.
**Addition:**
```javascript
import { StrictMode } from "react";
root.render(
<StrictMode>
<App />
</StrictMode>
);
```
**Benefits:**
- Detects unexpected side effects
- Warns about deprecated APIs
- Validates React 19 best practices
- Double-invokes effects in development to catch issues
**Impact:**
- Helps ensure components work correctly with React 19 compiler
- Catches potential issues with state management
- Comment added: "This handles React StrictMode double-mounting"
---
## 9. React 19 New Hooks (Added Documentation)
The upgrade includes documentation for React 19's new concurrent hooks:
### `useFormStatus`
Track form submission state for better UX during async operations.
### `useOptimistic`
Implement optimistic UI updates that rollback on failure.
### `useActionState`
Manage server actions with pending states and error handling.
---
## 10. ESLint Configuration Updates
### React Compiler Plugin Added
**Addition to eslint.config.js:**
```javascript
plugins: {
"react-compiler": pluginReactCompiler
},
rules: {
"react-compiler/react-compiler": "error"
}
```
**Purpose:**
- Enforces React 19 compiler best practices
- Warns about patterns that prevent compiler optimizations
- Ensures code is compatible with automatic optimizations
---
## 11. Testing Library Updates
### @testing-library/react
- **Upgraded:** ^16.3.1 → ^16.3.2
- **Impact:** React 19 compatibility maintained
- Tests continue to work with updated React APIs
---
## 12. Peer Dependencies Updates
Multiple packages updated their peer dependency requirements to support React 19:
**Examples:**
```json
// BEFORE
"peerDependencies": {
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
// AFTER
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
```
**Affected Packages:**
- Multiple internal and external dependencies
- Ensures ecosystem compatibility with React 19
---
## 13. Ant Design 6 Changes
### Icon Package Update
- @ant-design/icons upgraded from ^5.6.1 to ^6.1.0
- Icon imports remain compatible (no breaking changes in usage patterns)
### Component API Compatibility
- Existing Ant Design component usage remains largely compatible
- Form.Item, Button, Modal, Table, and other components work with existing code
- No major API breaking changes required in application code
---
## 14. Validation & Quality Assurance
Based on the optimization summary included in the changes:
### Deprecations Verified as Fixed ✓
- **propTypes:** None found (already removed or using TypeScript)
- **defaultProps:** None found (using default parameters instead)
- **ReactDOM.render:** Already using createRoot
- **componentWillMount/Receive/Update:** No legacy lifecycle methods found
- **String refs:** Migrated to ref objects and useRef hooks
### Performance Improvements
- Cleaner, more readable code
- Reduced bundle size (removed unnecessary memoization wrappers)
- Better performance through compiler-optimized memoization
- Fewer function closures and re-creations
- Reduced memory overhead from eliminated deep cloning
---
## Summary Statistics
### Dependencies Updated
- **Core:** 3 major updates (React, Ant Design, Apollo Client)
- **GraphQL:** 2 packages (Apollo Client 3→4, apollo-link-logger 2→3)
- **Ecosystem:** 10+ related libraries (router, i18next, grid layout, etc.)
- **Build Tools:** 3 plugins/tools (Vite plugins, vitest)
- **Monitoring:** 6 packages (Sentry, LogRocket, PostHog, Amplitude)
- **Infrastructure:** Node.js 22 → 24
### Code Patterns Modernized
- **useMemo removals:** 15+ instances across multiple files
- **useCallback removals:** 10+ instances
- **memo() wrapper removals:** 5+ components
- **Deep clone eliminations:** Multiple state management simplifications
- **Import cleanups:** Dozens of simplified import statements
- **Apollo import migrations:** 50+ files updated to `/react` imports
- **forwardRef removals:** 15+ components migrated to direct ref props
- **useLazyQuery updates:** Multiple query patterns updated for Apollo 4.x API
- **lazy import cleanups:** Several unnecessary lazy imports removed
- **StrictMode integration:** Added to development builds
### Files Impacted
- **Production board kanban components:** Compiler optimization removals
- **Trello-board controllers and components:** Memoization removals
- **Job management components:** State management simplifications
- **All GraphQL components:** Apollo Client 4.x import migrations (50+ files)
- **Form components:** forwardRef pattern migrations (15+ components)
- **Payment components:** useLazyQuery API updates
- **Various utility components:** Import cleanups
- **Build configuration files:** ESLint React compiler plugin
- **Docker infrastructure:** Node.js 22→24 upgrade
- **App root:** StrictMode integration
- **Package manifests:** 30+ dependency upgrades
---
## Recommendations for Future Development
1. **Avoid Manual Memoization:** Let React 19 compiler handle optimization automatically
2. **Use ESLint React Compiler Plugin:** Catch patterns that prevent optimizations
3. **Maintain Referential Stability:** Use constant empty arrays/objects instead of creating new ones
4. **Leverage New React 19 Hooks:** Use `useOptimistic`, `useFormStatus`, and `useActionState` for better UX
5. **Monitor Compiler Warnings:** Address any compiler optimization warnings during development
6. **Apollo Client 4.x Imports:** Always import React hooks from `@apollo/client/react`
7. **Ref as Props:** Use `ref` as a regular prop instead of `forwardRef` wrapper
8. **useLazyQuery Pattern:** Extract query function and call explicitly rather than using `skip` option
9. **StrictMode Aware:** Ensure components handle double-mounting in development properly
10. **Keep Dependencies Updated:** Monitor for peer dependency compatibility as ecosystem evolves
---
## Conclusion
This comprehensive upgrade successfully modernizes the codebase across multiple dimensions:
### Major Achievements
1. **React 19 Migration:** Leveraged new compiler optimizations by removing manual memoization
2. **Apollo Client 4.x:** Updated all GraphQL operations to new import patterns and APIs
3. **Ant Design 6:** Maintained UI consistency while gaining access to latest features
4. **forwardRef Elimination:** Simplified 15+ components by using refs as regular props
5. **Dependency Modernization:** Updated 30+ packages including monitoring, build tools, and ecosystem libraries
6. **Infrastructure Upgrade:** Node.js 24.x support for latest runtime features
### Code Quality Improvements
- **Cleaner code:** Removed unnecessary wrappers and boilerplate
- **Better performance:** Compiler-optimized rendering without manual hints
- **Reduced bundle size:** Removed lodash cloning, unnecessary lazy imports, and redundant memoization
- **Improved maintainability:** Simpler patterns that are easier to understand and modify
- **Enhanced DX:** ESLint integration catches optimization blockers during development
### Migration Completeness
✅ All React 18→19 deprecations addressed
✅ All Apollo Client 3→4 breaking changes handled
✅ All Ant Design 5→6 updates applied
✅ All monitoring libraries updated to latest versions
✅ StrictMode integration for development safety
✅ Comprehensive testing library compatibility maintained
**No breaking changes to application functionality** - The upgrade maintains backward compatibility in behavior while providing forward-looking improvements in implementation.

View File

@@ -18,4 +18,3 @@ 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_ENABLE_COMPILER_IN_DEV=1

View File

@@ -20,4 +20,3 @@ 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_ENABLE_COMPILER_IN_DEV=1

View File

@@ -16,4 +16,4 @@ VITE_APP_INSTANCE=IMEX
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_AMP_KEY=6228a598e57cd66875cfd41604f1f891

3
client/.gitignore vendored
View File

@@ -13,3 +13,6 @@ playwright/.cache/
# Sentry Config File
.sentryclirc
/dev-dist
# Local environment overrides (not version controlled)
.env.development.local.overrides

1424
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,9 +8,9 @@
"private": true,
"proxy": "http://localhost:4000",
"dependencies": {
"@amplitude/analytics-browser": "^2.33.4",
"@amplitude/analytics-browser": "^2.34.0",
"@ant-design/pro-layout": "^7.22.6",
"@apollo/client": "^4.0.13",
"@apollo/client": "^4.1.3",
"@emotion/is-prop-valid": "^1.4.0",
"@fingerprintjs/fingerprintjs": "^5.0.1",
"@firebase/analytics": "^0.10.19",
@@ -21,14 +21,14 @@
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.11.2",
"@sentry/cli": "^3.1.0",
"@sentry/react": "^10.34.0",
"@sentry/vite-plugin": "^4.6.2",
"@sentry/react": "^10.38.0",
"@sentry/vite-plugin": "^4.8.0",
"@splitsoftware/splitio-react": "^2.6.1",
"@tanem/react-nprogress": "^5.0.56",
"antd": "^6.2.0",
"@tanem/react-nprogress": "^5.0.58",
"antd": "^6.2.2",
"apollo-link-logger": "^3.0.0",
"autosize": "^6.0.1",
"axios": "^1.13.2",
"axios": "^1.13.4",
"classnames": "^2.5.1",
"css-box-model": "^1.2.1",
"dayjs": "^1.11.19",
@@ -38,31 +38,31 @@
"env-cmd": "^11.0.0",
"exifr": "^7.1.3",
"graphql": "^16.12.0",
"graphql-ws": "^6.0.6",
"i18next": "^25.7.4",
"graphql-ws": "^6.0.7",
"i18next": "^25.8.0",
"i18next-browser-languagedetector": "^8.2.0",
"immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.34",
"lightningcss": "^1.30.2",
"logrocket": "^11.0.0",
"libphonenumber-js": "^1.12.36",
"lightningcss": "^1.31.1",
"logrocket": "^12.0.0",
"markerjs2": "^2.32.7",
"memoize-one": "^6.0.0",
"normalize-url": "^8.1.1",
"object-hash": "^3.0.0",
"phone": "^3.1.69",
"posthog-js": "^1.322.0",
"phone": "^3.1.70",
"posthog-js": "^1.336.4",
"prop-types": "^15.8.1",
"query-string": "^9.3.1",
"raf-schd": "^4.0.3",
"react": "^19.2.3",
"react": "^19.2.4",
"react-big-calendar": "^1.19.4",
"react-color": "^2.19.3",
"react-cookie": "^8.0.1",
"react-dom": "^19.2.3",
"react-dom": "^19.2.4",
"react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.1",
"react-grid-layout": "^2.2.2",
"react-i18next": "^16.5.3",
"react-i18next": "^16.5.4",
"react-icons": "^5.5.0",
"react-image-lightbox": "^5.1.4",
"react-markdown": "^10.1.0",
@@ -71,10 +71,10 @@
"react-product-fruits": "^2.2.62",
"react-redux": "^9.2.0",
"react-resizable": "^3.1.3",
"react-router-dom": "^7.12.0",
"react-router-dom": "^7.13.0",
"react-sticky": "^6.0.3",
"react-virtuoso": "^4.18.1",
"recharts": "^3.6.0",
"recharts": "^3.7.0",
"redux": "^5.0.1",
"redux-actions": "^3.0.3",
"redux-persist": "^6.0.0",
@@ -82,9 +82,9 @@
"redux-state-sync": "^3.1.4",
"reselect": "^5.1.1",
"rxjs": "^7.8.2",
"sass": "^1.97.2",
"sass": "^1.97.3",
"socket.io-client": "^4.8.3",
"styled-components": "^6.3.6",
"styled-components": "^6.3.8",
"vite-plugin-ejs": "^1.7.0",
"web-vitals": "^5.1.0"
},
@@ -92,15 +92,17 @@
"postinstall": "echo 'when updating react-big-calendar, remember to check to localizer in the calendar wrapper'",
"analyze": "source-map-explorer 'build/static/js/*.js'",
"start": "vite",
"build": "dotenvx run --env-file=.env.development.imex -- vite build",
"start:imex": "dotenvx run --env-file=.env.development.imex -- vite",
"start:rome": "dotenvx run --env-file=.env.development.rome -- vite",
"preview:imex": "dotenvx run --env-file=.env.development.imex -- vite preview",
"preview:rome": "dotenvx run --env-file=.env.development.rome -- vite preview",
"build:test:imex": "env-cmd -f .env.test.imex -- npm run build",
"build:test:rome": "env-cmd -f .env.test.rome -- npm run build",
"build:production:imex": "env-cmd -f .env.production.imex -- npm run build",
"build:production:rome": "env-cmd -f .env.production.rome -- npm run build",
"build": "vite build",
"build:dev:imex": "dotenvx run --env-file=.env.development.imex --env-file=.env.development.local.overrides -- vite build",
"build:dev:rome": "dotenvx run --env-file=.env.development.rome --env-file=.env.development.local.overrides -- vite build",
"build:test:imex": "dotenvx run --env-file=.env.test.imex -- vite build",
"build:test:rome": "dotenvx run --env-file=.env.test.rome -- vite build",
"build:production:imex": "env-cmd -f .env.production.imex vite build",
"build:production:rome": "env-cmd -f .env.production.rome vite build",
"start:imex": "dotenvx run --env-file=.env.development.imex --env-file=.env.development.local.overrides -- vite",
"start:rome": "dotenvx run --env-file=.env.development.rome --env-file=.env.development.local.overrides -- vite",
"preview:imex": "dotenvx run --env-file=.env.development.imex --env-file=.env.development.local.overrides -- vite preview",
"preview:rome": "dotenvx run --env-file=.env.development.rome --env-file=.env.development.local.overrides -- vite preview",
"madge": "madge --image ./madge-graph.svg --extensions js,jsx,ts,tsx --circular .",
"eulaize": "node src/utils/eulaize.js",
"test:unit": "vitest run",
@@ -135,14 +137,14 @@
"@ant-design/icons": "^6.1.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.28.5",
"@dotenvx/dotenvx": "^1.51.4",
"@dotenvx/dotenvx": "^1.52.0",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.39.2",
"@playwright/test": "^1.57.0",
"@playwright/test": "^1.58.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/react": "^16.3.2",
"@vitejs/plugin-react": "^5.1.2",
"babel-plugin-react-compiler": "^1.0.0",
"browserslist": "^4.28.1",
@@ -151,11 +153,11 @@
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"globals": "^17.0.0",
"globals": "^17.2.0",
"jsdom": "^27.4.0",
"memfs": "^4.52.0",
"memfs": "^4.56.10",
"os-browserify": "^0.3.0",
"playwright": "^1.57.0",
"playwright": "^1.58.0",
"react-error-overlay": "^6.1.0",
"redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.3",
@@ -165,7 +167,7 @@
"vite-plugin-node-polyfills": "^0.25.0",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-style-import": "^2.0.0",
"vitest": "^4.0.17",
"vitest": "^4.0.18",
"workbox-window": "^7.4.0"
}
}

View File

@@ -6,8 +6,7 @@ import enLocale from "antd/es/locale/en_US";
import { useEffect, useMemo } from "react";
import { CookiesProvider } from "react-cookie";
import { useTranslation } from "react-i18next";
import { connect, useSelector } from "react-redux";
import { createStructuredSelector } from "reselect";
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";
@@ -28,93 +27,102 @@ const config = {
function SplitClientProvider({ children }) {
const imexshopid = useSelector((state) => state.user.imexshopid);
const splitClient = useSplitClient({ key: imexshopid || "anon" });
useEffect(() => {
if (splitClient && imexshopid) {
if (import.meta.env.DEV && splitClient && imexshopid) {
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
}
}, [splitClient, imexshopid]);
return children;
}
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser
});
const mapDispatchToProps = (dispatch) => ({
setDarkMode: (isDarkMode) => dispatch(setDarkMode(isDarkMode)),
signOutStart: () => dispatch(signOutStart())
});
function AppContainer({ currentUser, setDarkMode, signOutStart }) {
function AppContainer() {
const { t } = useTranslation();
const dispatch = useDispatch();
const currentUser = useSelector(selectCurrentUser);
const isDarkMode = useSelector(selectDarkMode);
const theme = useMemo(() => getTheme(isDarkMode), [isDarkMode]);
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
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;
const requestOrigin = event.origin;
// 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" },
requestOrigin || "*"
);
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin);
return;
}
signOutStart();
window.parent.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, requestOrigin || "*");
dispatch(signOutStart());
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin);
};
window.addEventListener("message", handleSeamlessLogout);
return () => {
window.removeEventListener("message", handleSeamlessLogout);
};
}, [signOutStart, currentUser]);
}, [dispatch, currentUser?.authorized]);
// Update data-theme attribute
// Update data-theme attribute (no cleanup to avoid transient style churn)
useEffect(() => {
document.documentElement.setAttribute("data-theme", isDarkMode ? "dark" : "light");
return () => document.documentElement.removeAttribute("data-theme");
document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
}, [isDarkMode]);
// Sync darkMode with localStorage
useEffect(() => {
if (currentUser?.uid) {
const savedMode = localStorage.getItem(`dark-mode-${currentUser.uid}`);
if (savedMode !== null) {
setDarkMode(JSON.parse(savedMode));
} else {
setDarkMode(false);
}
} else {
setDarkMode(false);
const uid = currentUser?.uid;
if (!uid) {
dispatch(setDarkMode(false));
return;
}
}, [currentUser?.uid, setDarkMode]);
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(() => {
if (currentUser?.uid) {
localStorage.setItem(`dark-mode-${currentUser.uid}`, JSON.stringify(isDarkMode));
}
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={{ autoComplete: "new-password" }}
locale={enLocale}
theme={theme}
form={{
validateMessages: {
required: t("general.validation.required", { label: "${label}" })
}
}}
>
<ConfigProvider input={antdInput} locale={enLocale} theme={theme} form={antdForm}>
<GlobalLoadingBar />
<SplitFactoryProvider config={config}>
<SplitClientProvider>
@@ -127,4 +135,4 @@ function AppContainer({ currentUser, setDarkMode, signOutStart }) {
);
}
export default Sentry.withProfiler(connect(mapStateToProps, mapDispatchToProps)(AppContainer));
export default Sentry.withProfiler(AppContainer);

View File

@@ -100,14 +100,7 @@ export function App({
if (currentUser.authorized && bodyshop) {
client.setAttribute("imexshopid", bodyshop.imexshopid);
if (
client.getTreatment("LogRocket_Tracking") === "on" ||
window.location.hostname ===
InstanceRenderMgr({
imex: "beta.imex.online",
rome: "beta.romeonline.io"
})
) {
if (client.getTreatment("LogRocket_Tracking") === "on") {
console.log("LR Start");
LogRocket.init(
InstanceRenderMgr({

View File

@@ -169,7 +169,13 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, bills, ref
refetch={refetch}
/>
{bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && <QboAuthorizeComponent />}
<Input value={state.search} onChange={handleSearch} placeholder={t("general.labels.search")} allowClear />
<Input
value={state.search}
onChange={handleSearch}
placeholder={t("general.labels.search")}
allowClear
enterButton
/>
</Space>
}
>

View File

@@ -182,7 +182,13 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, payments,
refetch={refetch}
/>
{bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && <QboAuthorizeComponent />}
<Input value={state.search} onChange={handleSearch} placeholder={t("general.labels.search")} allowClear />
<Input
value={state.search}
onChange={handleSearch}
placeholder={t("general.labels.search")}
allowClear
enterButton
/>
</Space>
}
>

View File

@@ -204,6 +204,7 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
onChange={handleSearch}
placeholder={t("general.labels.search")}
allowClear
enterButton
/>
</Space>
}

View File

@@ -77,13 +77,7 @@ export function BillDeleteButton({ bill, jobid, callback, insertAuditTrail }) {
return (
<RbacWrapper action="bills:delete" noauth={<></>}>
<Popconfirm disabled={bill.exported} onConfirm={handleDelete} title={t("bills.labels.deleteconfirm")}>
<Button
disabled={bill.exported}
// onClick={handleDelete}
loading={loading}
>
<DeleteFilled />
</Button>
<Button icon={<DeleteFilled />} disabled={bill.exported} loading={loading} />
</Popconfirm>
</RbacWrapper>
);

View File

@@ -56,7 +56,7 @@ export function BillDetailEditcontainer({ insertAuditTrail, bodyshop }) {
const handleSave = () => {
//It's got a previously deducted bill line!
if (
data.bills_by_pk.billlines.filter((b) => b.deductedfromlbr).length > 0 ||
data?.bills_by_pk?.billlines.filter((b) => b.deductedfromlbr).length > 0 ||
form.getFieldValue("billlines").filter((b) => b.deductedfromlbr).length > 0
)
setOpen(true);
@@ -84,7 +84,7 @@ export function BillDetailEditcontainer({ insertAuditTrail, bodyshop }) {
//Find bill lines that were deleted.
const deletedJobLines = [];
data.bills_by_pk.billlines.forEach((a) => {
data?.bills_by_pk?.billlines.forEach((a) => {
const matchingRecord = billlines.find((b) => b.id === a.id);
if (!matchingRecord) {
deletedJobLines.push(a);
@@ -151,8 +151,8 @@ export function BillDetailEditcontainer({ insertAuditTrail, bodyshop }) {
if (error) return <AlertComponent title={error.message} type="error" />;
if (!search.billid) return <></>; //<div>{t("bills.labels.noneselected")}</div>;
const exported = data?.bills_by_pk && data.bills_by_pk.exported;
const isinhouse = data?.bills_by_pk && data.bills_by_pk.isinhouse;
const exported = data?.bills_by_pk && data?.bills_by_pk?.exported;
const isinhouse = data?.bills_by_pk && data?.bills_by_pk?.isinhouse;
return (
<>
@@ -160,7 +160,7 @@ export function BillDetailEditcontainer({ insertAuditTrail, bodyshop }) {
{data && (
<>
<PageHeader
title={data && `${data.bills_by_pk.invoice_number} - ${data.bills_by_pk.vendor.name}`}
title={data && `${data?.bills_by_pk?.invoice_number} - ${data?.bills_by_pk?.vendor?.name}`}
extra={
<Space>
<BillDetailEditReturn data={data} />
@@ -192,15 +192,15 @@ export function BillDetailEditcontainer({ insertAuditTrail, bodyshop }) {
<Divider titlePlacement="left">{t("general.labels.media")}</Divider>
{bodyshop.uselocalmediaserver ? (
<JobsDocumentsLocalGallery
job={{ id: data ? data.bills_by_pk.jobid : null }}
invoice_number={data ? data.bills_by_pk.invoice_number : null}
vendorid={data ? data.bills_by_pk.vendorid : null}
job={{ id: data ? data?.bills_by_pk?.jobid : null }}
invoice_number={data ? data?.bills_by_pk?.invoice_number : null}
vendorid={data ? data?.bills_by_pk?.vendorid : null}
/>
) : (
<JobDocumentsGallery
jobId={data ? data.bills_by_pk.jobid : null}
jobId={data ? data?.bills_by_pk?.jobid : null}
billId={search.billid}
documentsList={data ? data.bills_by_pk.documents : []}
documentsList={data ? data?.bills_by_pk?.documents : []}
billsCallback={refetch}
/>
)}
@@ -212,7 +212,7 @@ export function BillDetailEditcontainer({ insertAuditTrail, bodyshop }) {
}
const transformData = (data) => {
return data
return data?.bills_by_pk
? {
...data.bills_by_pk,

View File

@@ -48,7 +48,7 @@ export function BillDetailEditReturn({ setPartsOrderContext, data, disabled }) {
// db_price: i.actual_price,
act_price: i.actual_price,
cost: i.actual_cost,
quantity: i.quantity,
part_qty: i.quantity,
joblineid: i.joblineid,
oem_partno: i.jobline && i.jobline.oem_partno,
part_type: i.jobline && i.jobline.part_type
@@ -104,6 +104,10 @@ export function BillDetailEditReturn({ setPartsOrderContext, data, disabled }) {
{fields.map((field, index) => (
<tr key={field.key}>
<td>
{/* Hidden field to preserve the id */}
<Form.Item name={[field.name, "id"]} hidden>
<input type="hidden" />
</Form.Item>
<Form.Item
// label={t("joblines.fields.selected")}
key={`${index}selected`}

View File

@@ -32,6 +32,7 @@ export function BillFormItemsExtendedFormItem({
if (!value)
return (
<Button
icon={<PlusCircleFilled />}
onClick={() => {
const values = form.getFieldsValue("billlineskeys");
@@ -53,9 +54,7 @@ export function BillFormItemsExtendedFormItem({
}
});
}}
>
<PlusCircleFilled />
</Button>
/>
);
return (
@@ -196,6 +195,7 @@ export function BillFormItemsExtendedFormItem({
</Form.Item>
<Button
icon={<MinusCircleFilled />}
onClick={() => {
const values = form.getFieldsValue("billlineskeys");
@@ -207,9 +207,7 @@ export function BillFormItemsExtendedFormItem({
}
});
}}
>
<MinusCircleFilled />
</Button>
/>
</Space>
);
}

View File

@@ -98,17 +98,19 @@ export function BillFormComponent({
}
const jobId = form.getFieldValue("jobid");
if (jobId) {
loadLines({ id: jobId });
loadLines({ variables: { id: jobId } });
if (form.getFieldValue("is_credit_memo") && vendorId && !billEdit) {
loadOutstandingReturns({
jobId: jobId,
vendorId: vendorId
variables: {
jobId: jobId,
vendorId: vendorId
}
});
}
}
if (vendorId === bodyshop.inhousevendorid && !billEdit) {
loadInventory();
loadInventory({ variables: {} });
}
}, [
form,
@@ -124,7 +126,7 @@ export function BillFormComponent({
return (
<div>
<FormFieldsChanged form={form} />
<Form.Item style={{ display: "none" }} name="isinhouse" valuePropName="checked">
<Form.Item hidden name="isinhouse" valuePropName="checked">
<Switch />
</Form.Item>
<LayoutFormRow grow>
@@ -144,11 +146,13 @@ export function BillFormComponent({
notExported={false}
onBlur={() => {
if (form.getFieldValue("jobid") !== null && form.getFieldValue("jobid") !== undefined) {
loadLines({ id: form.getFieldValue("jobid") });
loadLines({ variables: { id: form.getFieldValue("jobid") } });
if (form.getFieldValue("vendorid") !== null && form.getFieldValue("vendorid") !== undefined) {
loadOutstandingReturns({
jobId: form.getFieldValue("jobid"),
vendorId: form.getFieldValue("vendorid")
variables: {
jobId: form.getFieldValue("jobid"),
vendorId: form.getFieldValue("vendorid")
}
});
}
}
@@ -189,7 +193,7 @@ export function BillFormComponent({
<Alert
key={iou.id}
type="warning"
message={
title={
<Space>
{t("bills.labels.iouexists")}
<Link target="_blank" rel="noopener noreferrer" to={`/manage/jobs/${iou.id}?tab=repairdata`}>

View File

@@ -5,6 +5,7 @@ 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 CiecaSelect from "../../utils/Ciecaselect";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import BillLineSearchSelect from "../bill-line-search-select/bill-line-search-select.component";
@@ -13,15 +14,15 @@ import CurrencyInput from "../form-items-formatted/currency-form-item.component"
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
bodyshop: selectBodyshop,
isDarkMode: selectDarkMode
});
const mapDispatchToProps = () => ({});
export function BillEnterModalLinesComponent({
bodyshop,
isDarkMode,
disabled,
lineData,
discount,
@@ -32,6 +33,99 @@ export function BillEnterModalLinesComponent({
const { t } = useTranslation();
const { setFieldsValue, getFieldsValue, getFieldValue } = form;
const CONTROL_HEIGHT = 32;
const normalizeDiscount = (d) => {
const n = Number(d);
if (!Number.isFinite(n) || n <= 0) return 0;
return n > 1 ? n / 100 : n;
};
const round2 = (v) => Math.round((v + Number.EPSILON) * 100) / 100;
const isBlank = (v) => v === null || v === undefined || v === "" || Number.isNaN(v);
const toNumber = (raw) => {
if (raw === null || raw === undefined) return NaN;
if (typeof raw === "number") return raw;
if (typeof raw === "string") {
const cleaned = raw
.trim()
.replace(/[^\d.,-]/g, "")
.replace(/,/g, "");
return Number.parseFloat(cleaned);
}
if (typeof raw === "object") {
try {
if (typeof raw.toNumber === "function") return raw.toNumber();
const v = raw.valueOf?.();
if (typeof v === "number") return v;
if (typeof v === "string") {
const cleaned = v
.trim()
.replace(/[^\d.,-]/g, "")
.replace(/,/g, "");
return Number.parseFloat(cleaned);
}
} catch {
// ignore
}
}
return NaN;
};
const setLineField = (index, field, value) => {
if (typeof form.setFieldValue === "function") {
form.setFieldValue(["billlines", index, field], value);
return;
}
const lines = form.getFieldValue("billlines") || [];
form.setFieldsValue({
billlines: lines.map((l, i) => (i === index ? { ...l, [field]: value } : l))
});
};
const autofillActualCost = (index) => {
Promise.resolve().then(() => {
const retailRaw = form.getFieldValue(["billlines", index, "actual_price"]);
const actualRaw = form.getFieldValue(["billlines", index, "actual_cost"]);
const d = normalizeDiscount(discount);
if (!isBlank(actualRaw)) return;
const retail = toNumber(retailRaw);
if (!Number.isFinite(retail)) return;
const next = round2(retail * (1 - d));
setLineField(index, "actual_cost", next);
});
};
const getIndicatorColor = (lineDiscount) => {
const d = normalizeDiscount(discount);
if (Math.abs(lineDiscount - d) > 0.005) return lineDiscount > d ? "orange" : "red";
return "green";
};
const getIndicatorShellStyles = (statusColor) => {
if (isDarkMode) {
if (statusColor === "green")
return { borderColor: "rgba(82, 196, 26, 0.75)", background: "rgba(82, 196, 26, 0.10)" };
if (statusColor === "orange")
return { borderColor: "rgba(250, 173, 20, 0.75)", background: "rgba(250, 173, 20, 0.10)" };
return { borderColor: "rgba(255, 77, 79, 0.75)", background: "rgba(255, 77, 79, 0.10)" };
}
if (statusColor === "green") return { borderColor: "#b7eb8f", background: "#f6ffed" };
if (statusColor === "orange") return { borderColor: "#ffe58f", background: "#fffbe6" };
return { borderColor: "#ffccc7", background: "#fff2f0" };
};
const {
treatments: { Simple_Inventory, Enhanced_Payroll }
} = useTreatmentsWithConfig({
@@ -47,24 +141,15 @@ export function BillEnterModalLinesComponent({
dataIndex: "joblineid",
editable: true,
minWidth: "10rem",
formItemProps: (field) => {
return {
key: `${field.index}joblinename`,
name: [field.name, "joblineid"],
label: t("billlines.fields.jobline"),
rules: [
{
required: true
//message: t("general.validation.required"),
}
]
};
},
formItemProps: (field) => ({
key: `${field.name}joblinename`,
name: [field.name, "joblineid"],
label: t("billlines.fields.jobline"),
rules: [{ required: true }]
}),
wrapper: (props) => (
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.is_credit_memo !== cur.is_credit_memo}>
{() => {
return props.children;
}}
{() => props.children}
</Form.Item>
),
formInput: (record, index) => (
@@ -72,35 +157,37 @@ export function BillEnterModalLinesComponent({
disabled={disabled}
options={lineData}
style={{
//width: "10rem",
// maxWidth: "20rem",
minWidth: "20rem",
whiteSpace: "normal",
height: "auto",
minHeight: "32px" // default height of Ant Design inputs
minHeight: `${CONTROL_HEIGHT}px`
}}
allowRemoved={form.getFieldValue("is_credit_memo") || false}
onSelect={(value, opt) => {
const d = normalizeDiscount(discount);
const retail = Number(opt.cost);
const computedActual = Number.isFinite(retail) ? round2(retail * (1 - d)) : null;
setFieldsValue({
billlines: getFieldsValue(["billlines"]).billlines.map((item, idx) => {
if (idx === index) {
return {
...item,
line_desc: opt.line_desc,
quantity: opt.part_qty || 1,
actual_price: opt.cost,
original_actual_price: opt.cost,
cost_center: opt.part_type
? bodyshopHasDmsKey(bodyshop)
? opt.part_type !== "PAE"
? opt.part_type
: null
: responsibilityCenters.defaults &&
(responsibilityCenters.defaults.costs[opt.part_type] || null)
: null
};
}
return item;
billlines: (getFieldValue("billlines") || []).map((item, idx) => {
if (idx !== index) return item;
return {
...item,
line_desc: opt.line_desc,
quantity: opt.part_qty || 1,
actual_price: opt.cost,
original_actual_price: opt.cost,
actual_cost: isBlank(item.actual_cost) ? computedActual : item.actual_cost,
cost_center: opt.part_type
? bodyshopHasDmsKey(bodyshop)
? opt.part_type !== "PAE"
? opt.part_type
: null
: responsibilityCenters.defaults &&
(responsibilityCenters.defaults.costs[opt.part_type] || null)
: null
};
})
});
}}
@@ -112,19 +199,12 @@ export function BillEnterModalLinesComponent({
dataIndex: "line_desc",
editable: true,
minWidth: "10rem",
formItemProps: (field) => {
return {
key: `${field.index}line_desc`,
name: [field.name, "line_desc"],
label: t("billlines.fields.line_desc"),
rules: [
{
required: true
//message: t("general.validation.required"),
}
]
};
},
formItemProps: (field) => ({
key: `${field.name}line_desc`,
name: [field.name, "line_desc"],
label: t("billlines.fields.line_desc"),
rules: [{ required: true }]
}),
formInput: () => <Input.TextArea disabled={disabled} autoSize />
},
{
@@ -132,31 +212,28 @@ export function BillEnterModalLinesComponent({
dataIndex: "quantity",
editable: true,
width: "4rem",
formItemProps: (field) => {
return {
key: `${field.index}quantity`,
name: [field.name, "quantity"],
label: t("billlines.fields.quantity"),
rules: [
{
required: true
//message: t("general.validation.required"),
},
({ getFieldValue }) => ({
validator(rule, value) {
if (value && getFieldValue("billlines")[field.fieldKey]?.inventories?.length > value) {
return Promise.reject(
t("bills.validation.inventoryquantity", {
number: getFieldValue("billlines")[field.fieldKey]?.inventories?.length
})
);
}
return Promise.resolve();
formItemProps: (field) => ({
key: `${field.name}quantity`,
name: [field.name, "quantity"],
label: t("billlines.fields.quantity"),
rules: [
{ required: true },
({ getFieldValue: gf }) => ({
validator(_, value) {
const invLen = gf(["billlines", field.name, "inventories"])?.length ?? 0;
if (value && invLen > value) {
return Promise.reject(
t("bills.validation.inventoryquantity", {
number: invLen
})
);
}
})
]
};
},
return Promise.resolve();
}
})
]
}),
formInput: () => <InputNumber precision={0} min={1} disabled={disabled} />
},
{
@@ -164,37 +241,19 @@ export function BillEnterModalLinesComponent({
dataIndex: "actual_price",
width: "8rem",
editable: true,
formItemProps: (field) => {
return {
key: `${field.index}actual_price`,
name: [field.name, "actual_price"],
label: t("billlines.fields.actual_price"),
rules: [
{
required: true
//message: t("general.validation.required"),
}
]
};
},
formItemProps: (field) => ({
key: `${field.name}actual_price`,
name: [field.name, "actual_price"],
label: t("billlines.fields.actual_price"),
rules: [{ required: true }]
}),
formInput: (record, index) => (
<CurrencyInput
min={0}
disabled={disabled}
onBlur={(e) => {
setFieldsValue({
billlines: getFieldsValue("billlines").billlines.map((item, idx) => {
if (idx === index) {
return {
...item,
actual_cost: item.actual_cost
? item.actual_cost
: Math.round((parseFloat(e.target.value) * (1 - discount) + Number.EPSILON) * 100) / 100
};
}
return item;
})
});
onBlur={() => autofillActualCost(index)}
onKeyDown={(e) => {
if (e.key === "Tab") autofillActualCost(index);
}}
/>
),
@@ -221,9 +280,8 @@ export function BillEnterModalLinesComponent({
{t("joblines.fields.create_ppc")}
</Space>
);
} else {
return null;
}
return null;
}}
</Form.Item>
)
@@ -234,93 +292,105 @@ export function BillEnterModalLinesComponent({
dataIndex: "actual_cost",
editable: true,
width: "10rem",
skipFormItem: true,
formItemProps: (field) => ({
key: `${field.name}actual_cost`,
name: [field.name, "actual_cost"],
label: t("billlines.fields.actual_cost"),
rules: [{ required: true }]
}),
formInput: (record, index, fieldProps) => {
const { name, rules, valuePropName, getValueFromEvent, normalize, validateTrigger, initialValue } =
fieldProps || {};
formItemProps: (field) => {
return {
key: `${field.index}actual_cost`,
name: [field.name, "actual_cost"],
label: t("billlines.fields.actual_cost"),
rules: [
{
required: true
//message: t("general.validation.required"),
}
]
const bindProps = {
name,
rules,
valuePropName,
getValueFromEvent,
normalize,
validateTrigger,
initialValue
};
},
formInput: (record, index) => (
<CurrencyInput
min={0}
disabled={disabled}
controls={false}
addonAfter={
return (
<div
style={{
display: "flex",
width: "100%",
alignItems: "center",
height: CONTROL_HEIGHT
}}
>
<div style={{ flex: "1 1 auto", minWidth: 0 }}>
<Form.Item noStyle {...bindProps}>
<CurrencyInput
min={0}
disabled={disabled}
controls={false}
style={{ width: "100%", height: CONTROL_HEIGHT }}
onFocus={() => autofillActualCost(index)}
/>
</Form.Item>
</div>
<Form.Item shouldUpdate noStyle>
{() => {
const line = getFieldsValue(["billlines"]).billlines[index];
const all = getFieldsValue(["billlines"]);
const line = all?.billlines?.[index];
if (!line) return null;
let lineDiscount = 1 - line.actual_cost / line.actual_price;
if (isNaN(lineDiscount)) lineDiscount = 0;
const ap = toNumber(line.actual_price);
const ac = toNumber(line.actual_cost);
let lineDiscount = 0;
if (Number.isFinite(ap) && ap !== 0 && Number.isFinite(ac)) {
lineDiscount = 1 - ac / ap;
}
const statusColor = getIndicatorColor(lineDiscount);
const shell = getIndicatorShellStyles(statusColor);
return (
<Tooltip title={`${(lineDiscount * 100).toFixed(2) || 0}%`}>
<DollarCircleFilled
<div
style={{
color:
Math.abs(lineDiscount - discount) > 0.005
? lineDiscount > discount
? "orange"
: "red"
: "green"
height: CONTROL_HEIGHT,
minWidth: CONTROL_HEIGHT,
padding: "0 10px",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxSizing: "border-box",
borderStyle: "solid",
borderWidth: 1,
borderLeftWidth: 0,
...shell,
borderTopRightRadius: 6,
borderBottomRightRadius: 6
}}
/>
>
<DollarCircleFilled style={{ color: statusColor, lineHeight: 1 }} />
</div>
</Tooltip>
);
}}
</Form.Item>
}
/>
)
// additional: (record, index) => (
// <Form.Item shouldUpdate>
// {() => {
// const line = getFieldsValue(["billlines"]).billlines[index];
// if (!!!line) return null;
// const lineDiscount = (
// 1 -
// Math.round((line.actual_cost / line.actual_price) * 100) / 100
// ).toPrecision(2);
// return (
// <Tooltip title={`${(lineDiscount * 100).toFixed(0) || 0}%`}>
// <DollarCircleFilled
// style={{
// color: lineDiscount - discount !== 0 ? "red" : "green",
// }}
// />
// </Tooltip>
// );
// }}
// </Form.Item>
// ),
</div>
);
}
},
{
title: t("billlines.fields.cost_center"),
dataIndex: "cost_center",
editable: true,
formItemProps: (field) => {
return {
key: `${field.index}cost_center`,
name: [field.name, "cost_center"],
label: t("billlines.fields.cost_center"),
valuePropName: "value",
rules: [
{
required: true
//message: t("general.validation.required"),
}
]
};
},
formItemProps: (field) => ({
key: `${field.name}cost_center`,
name: [field.name, "cost_center"],
label: t("billlines.fields.cost_center"),
valuePropName: "value",
rules: [{ required: true }]
}),
formInput: () => (
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled}>
{bodyshopHasDmsKey(bodyshop)
@@ -337,12 +407,10 @@ export function BillEnterModalLinesComponent({
dataIndex: "location",
editable: true,
label: t("billlines.fields.location"),
formItemProps: (field) => {
return {
key: `${field.index}location`,
name: [field.name, "location"]
};
},
formItemProps: (field) => ({
key: `${field.name}location`,
name: [field.name, "location"]
}),
formInput: () => (
<Select disabled={disabled}>
{bodyshop.md_parts_locations.map((loc, idx) => (
@@ -359,25 +427,19 @@ export function BillEnterModalLinesComponent({
dataIndex: "deductedfromlbr",
editable: true,
width: "40px",
formItemProps: (field) => {
return {
valuePropName: "checked",
key: `${field.index}deductedfromlbr`,
name: [field.name, "deductedfromlbr"]
};
},
formItemProps: (field) => ({
valuePropName: "checked",
key: `${field.name}deductedfromlbr`,
name: [field.name, "deductedfromlbr"]
}),
formInput: () => <Switch disabled={disabled} />,
additional: (record, index) => (
<Form.Item shouldUpdate noStyle style={{ display: "inline-block" }}>
{() => {
const price = getFieldValue(["billlines", record.name, "actual_price"]);
const adjustmentRate = getFieldValue(["billlines", record.name, "lbr_adjustment", "rate"]);
const billline = getFieldValue(["billlines", record.name]);
const jobline = lineData.find((line) => line.id === billline?.joblineid);
const employeeTeamName = bodyshop.employee_teams.find((team) => team.id === jobline?.assigned_team);
if (getFieldValue(["billlines", record.name, "deductedfromlbr"]))
@@ -385,9 +447,7 @@ export function BillEnterModalLinesComponent({
<div>
{Enhanced_Payroll.treatment === "on" ? (
<Space>
{t("joblines.fields.assigned_team", {
name: employeeTeamName?.name
})}
{t("joblines.fields.assigned_team", { name: employeeTeamName?.name })}
{`${jobline.mod_lb_hrs} units/${t(`joblines.fields.lbr_types.${jobline.mod_lbr_ty}`)}`}
</Space>
) : null}
@@ -396,12 +456,7 @@ export function BillEnterModalLinesComponent({
label={t("joblines.fields.mod_lbr_ty")}
key={`${index}modlbrty`}
initialValue={jobline ? jobline.mod_lbr_ty : null}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
rules={[{ required: true }]}
name={[record.name, "lbr_adjustment", "mod_lbr_ty"]}
>
<Select allowClear>
@@ -421,16 +476,12 @@ export function BillEnterModalLinesComponent({
<Select.Option value="LA4">{t("joblines.fields.lbr_types.LA4")}</Select.Option>
</Select>
</Form.Item>
{Enhanced_Payroll.treatment === "on" ? (
<Form.Item
label={t("billlines.labels.mod_lbr_adjustment")}
name={[record.name, "lbr_adjustment", "mod_lb_hrs"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
rules={[{ required: true }]}
>
<InputNumber precision={5} min={0.01} max={jobline ? jobline.mod_lb_hrs : 0} />
</Form.Item>
@@ -439,12 +490,7 @@ export function BillEnterModalLinesComponent({
label={t("jobs.labels.adjustmentrate")}
name={[record.name, "lbr_adjustment", "rate"]}
initialValue={bodyshop.default_adjustment_rate}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
rules={[{ required: true }]}
>
<InputNumber precision={2} min={0.01} />
</Form.Item>
@@ -453,6 +499,7 @@ export function BillEnterModalLinesComponent({
<Space>{price && adjustmentRate && `${(price / adjustmentRate).toFixed(1)} hrs`}</Space>
</div>
);
return <></>;
}}
</Form.Item>
@@ -467,17 +514,11 @@ export function BillEnterModalLinesComponent({
dataIndex: "applicable_taxes.federal",
editable: true,
width: "40px",
formItemProps: (field) => {
return {
key: `${field.index}fedtax`,
valuePropName: "checked",
initialValue: InstanceRenderManager({
imex: true,
rome: false
}),
name: [field.name, "applicable_taxes", "federal"]
};
},
formItemProps: (field) => ({
key: `${field.name}fedtax`,
valuePropName: "checked",
name: [field.name, "applicable_taxes", "federal"]
}),
formInput: () => <Switch disabled={disabled} />
}
]
@@ -488,13 +529,11 @@ export function BillEnterModalLinesComponent({
dataIndex: "applicable_taxes.state",
editable: true,
width: "40px",
formItemProps: (field) => {
return {
key: `${field.index}statetax`,
valuePropName: "checked",
name: [field.name, "applicable_taxes", "state"]
};
},
formItemProps: (field) => ({
key: `${field.name}statetax`,
valuePropName: "checked",
name: [field.name, "applicable_taxes", "state"]
}),
formInput: () => <Switch disabled={disabled} />
},
@@ -506,40 +545,43 @@ export function BillEnterModalLinesComponent({
dataIndex: "applicable_taxes.local",
editable: true,
width: "40px",
formItemProps: (field) => {
return {
key: `${field.index}localtax`,
valuePropName: "checked",
name: [field.name, "applicable_taxes", "local"]
};
},
formItemProps: (field) => ({
key: `${field.name}localtax`,
valuePropName: "checked",
name: [field.name, "applicable_taxes", "local"]
}),
formInput: () => <Switch disabled={disabled} />
}
]
}),
{
title: t("general.labels.actions"),
dataIndex: "actions",
render: (text, record) => (
<Form.Item shouldUpdate noStyle>
{() => (
<Space wrap>
<Button
disabled={disabled || getFieldValue("billlines")[record.fieldKey]?.inventories?.length > 0}
onClick={() => remove(record.name)}
>
<DeleteFilled />
</Button>
{Simple_Inventory.treatment === "on" && (
<BilllineAddInventory
disabled={!billEdit || form.isFieldsTouched() || form.getFieldValue("is_credit_memo")}
billline={getFieldValue("billlines")[record.fieldKey]}
jobid={getFieldValue("jobid")}
{() => {
const currentLine = getFieldValue(["billlines", record.name]);
const invLen = currentLine?.inventories?.length ?? 0;
return (
<Space wrap>
<Button
icon={<DeleteFilled />}
disabled={disabled || invLen > 0}
onClick={() => remove(record.name)}
/>
)}
</Space>
)}
{Simple_Inventory.treatment === "on" && (
<BilllineAddInventory
disabled={!billEdit || form.isFieldsTouched() || form.getFieldValue("is_credit_memo")}
billline={currentLine}
jobid={getFieldValue("jobid")}
/>
)}
</Space>
);
}}
</Form.Item>
)
}
@@ -549,6 +591,7 @@ export function BillEnterModalLinesComponent({
const mergedColumns = (remove) =>
columns(remove).map((col) => {
if (!col.editable) return col;
return {
...col,
onCell: (record) => ({
@@ -556,8 +599,8 @@ export function BillEnterModalLinesComponent({
formItemProps: col.formItemProps,
formInput: col.formInput,
additional: col.additional,
dataIndex: col.dataIndex,
title: col.title
wrapper: col.wrapper,
skipFormItem: col.skipFormItem
})
};
});
@@ -576,33 +619,41 @@ export function BillEnterModalLinesComponent({
]}
>
{(fields, { add, remove }) => {
const hasRows = fields.length > 0;
return (
<>
<Table
components={{
body: {
cell: EditableCell
}
}}
className="bill-lines-table"
components={{ body: { cell: EditableCell } }}
size="small"
bordered
dataSource={fields}
rowKey="key"
columns={mergedColumns(remove)}
scroll={{ x: true }}
scroll={hasRows ? { x: "max-content" } : undefined}
pagination={false}
rowClassName="editable-row"
/>
<Form.Item>
<Button
disabled={disabled}
onClick={() => {
add();
}}
style={{ width: "100%" }}
>
{t("billlines.actions.newline")}
</Button>
</Form.Item>
<div style={{ marginTop: 12 }}>
<Form.Item style={{ marginBottom: 0 }}>
<Button
disabled={disabled}
onClick={() => {
add(
InstanceRenderManager({
imex: { applicable_taxes: { federal: true } },
rome: { applicable_taxes: { federal: false } }
})
);
}}
style={{ width: "100%" }}
>
{t("billlines.actions.newline")}
</Button>
</Form.Item>
</div>
</>
);
}}
@@ -613,55 +664,50 @@ export function BillEnterModalLinesComponent({
export default connect(mapStateToProps, mapDispatchToProps)(BillEnterModalLinesComponent);
const EditableCell = ({
dataIndex,
record,
children,
formInput,
formItemProps,
additional,
wrapper: Wrapper,
skipFormItem,
...restProps
}) => {
const rawProps = formItemProps?.(record);
// DO NOT mutate rawProps; omit `key` immutably
const propsFinal = rawProps
? (() => {
const { ...rest } = rawProps;
// eslint-disable-next-line no-unused-vars
const { key, ...rest } = rawProps;
return rest;
})()
: undefined;
if (additional) {
return (
<td {...restProps}>
<div>
<Form.Item name={dataIndex} labelCol={{ span: 0 }} {...propsFinal}>
{(formInput && formInput(record, record.name)) || children}
</Form.Item>
{additional(record, record.name)}
</div>
</td>
);
}
const control = skipFormItem ? (
(formInput && formInput(record, record.name, propsFinal)) || children
) : (
<Form.Item labelCol={{ span: 0 }} {...propsFinal} style={{ marginBottom: 0 }}>
{(formInput && formInput(record, record.name, propsFinal)) || children}
</Form.Item>
);
if (Wrapper) {
return (
<Wrapper>
<td {...restProps}>
<Form.Item labelCol={{ span: 0 }} name={dataIndex} {...propsFinal}>
{(formInput && formInput(record, record.name)) || children}
</Form.Item>
</td>
</Wrapper>
);
}
const cellInner = additional ? (
<div>
{control}
{additional(record, record.name)}
</div>
) : (
control
);
return (
<td {...restProps}>
<Form.Item labelCol={{ span: 0 }} name={dataIndex} {...propsFinal}>
{(formInput && formInput(record, record.name)) || children}
</Form.Item>
const { style: tdStyle, ...tdRest } = restProps;
const td = (
<td {...tdRest} style={{ ...tdStyle, verticalAlign: "middle" }}>
{cellInner}
</td>
);
if (Wrapper) return <Wrapper>{td}</Wrapper>;
return td;
};

View File

@@ -17,119 +17,137 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
});
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
const mapDispatchToProps = () => ({});
export default connect(mapStateToProps, mapDispatchToProps)(BilllineAddInventory);
export function BilllineAddInventory({ currentUser, bodyshop, billline, disabled, jobid }) {
const [loading, setLoading] = useState(false);
const { billid } = queryString.parse(useLocation().search);
const qs = queryString.parse(useLocation().search);
const billid = qs?.billid != null ? String(qs.billid) : null;
const [insertInventoryLine] = useMutation(INSERT_INVENTORY_AND_CREDIT);
const notification = useNotification();
const inventoryCount = billline?.inventories?.length ?? 0;
const quantity = billline?.quantity ?? 0;
const addToInventory = async () => {
setLoading(true);
if (loading) return;
//Check to make sure there are no existing items already in the inventory.
const cm = {
vendorid: bodyshop.inhousevendorid,
invoice_number: "ih",
jobid: jobid,
isinhouse: true,
is_credit_memo: true,
date: dayjs().format("YYYY-MM-DD"),
federal_tax_rate: bodyshop.bill_tax_rates.federal_tax_rate,
state_tax_rate: bodyshop.bill_tax_rates.state_tax_rate,
local_tax_rate: bodyshop.bill_tax_rates.local_tax_rate,
total: 0,
billlines: [
{
actual_price: billline.actual_price,
actual_cost: billline.actual_cost,
quantity: billline.quantity,
line_desc: billline.line_desc,
cost_center: billline.cost_center,
deductedfromlbr: billline.deductedfromlbr,
applicable_taxes: {
local: billline.applicable_taxes.local,
state: billline.applicable_taxes.state,
federal: billline.applicable_taxes.federal
}
}
]
};
cm.total = CalculateBillTotal(cm).enteredTotal.getAmount() / 100;
const insertResult = await insertInventoryLine({
variables: {
joblineId: billline.joblineid === "noline" ? billline.id : billline.joblineid, //This will return null as there will be no jobline that has the id of the bill line.
//Unfortunately, we can't send null as the GQL syntax validation fails.
joblineStatus: bodyshop.md_order_statuses.default_returned,
inv: {
shopid: bodyshop.id,
billlineid: billline.id,
actual_price: billline.actual_price,
actual_cost: billline.actual_cost,
quantity: billline.quantity,
line_desc: billline.line_desc
},
cm: { ...cm, billlines: { data: cm.billlines } }, //Fix structure for apollo insert.
pol: {
returnfrombill: billid,
vendorid: bodyshop.inhousevendorid,
deliver_by: dayjs().format("YYYY-MM-DD"),
parts_order_lines: {
data: [
{
line_desc: billline.line_desc,
act_price: billline.actual_price,
cost: billline.actual_cost,
quantity: billline.quantity,
job_line_id: billline.joblineid === "noline" ? null : billline.joblineid,
part_type: billline.jobline && billline.jobline.part_type,
cm_received: true
}
]
},
order_date: "2022-06-01",
orderedby: currentUser.email,
jobid: jobid,
user_email: currentUser.email,
return: true,
status: "Ordered"
}
},
refetchQueries: ["QUERY_BILL_BY_PK"]
});
if (!insertResult.errors) {
notification.success({
title: t("inventory.successes.inserted")
});
} else {
// Defensive: row identity can transiently desync during remove/add reindexing.
if (!billline) {
notification.error({
title: t("inventory.errors.inserting", {
error: JSON.stringify(insertResult.errors)
})
title: t("inventory.errors.inserting", { error: "Bill line is missing (please try again)." })
});
return;
}
setLoading(false);
setLoading(true);
try {
const taxes = billline?.applicable_taxes ?? {};
const cm = {
vendorid: bodyshop.inhousevendorid,
invoice_number: "ih",
jobid: jobid,
isinhouse: true,
is_credit_memo: true,
date: dayjs().format("YYYY-MM-DD"),
federal_tax_rate: bodyshop.bill_tax_rates.federal_tax_rate,
state_tax_rate: bodyshop.bill_tax_rates.state_tax_rate,
local_tax_rate: bodyshop.bill_tax_rates.local_tax_rate,
total: 0,
billlines: [
{
actual_price: billline.actual_price,
actual_cost: billline.actual_cost,
quantity: billline.quantity,
line_desc: billline.line_desc,
cost_center: billline.cost_center,
deductedfromlbr: billline.deductedfromlbr,
applicable_taxes: {
local: taxes.local,
state: taxes.state,
federal: taxes.federal
}
}
]
};
cm.total = CalculateBillTotal(cm).enteredTotal.getAmount() / 100;
const insertResult = await insertInventoryLine({
variables: {
joblineId: billline.joblineid === "noline" ? billline.id : billline.joblineid,
joblineStatus: bodyshop.md_order_statuses.default_returned,
inv: {
shopid: bodyshop.id,
billlineid: billline.id,
actual_price: billline.actual_price,
actual_cost: billline.actual_cost,
quantity: billline.quantity,
line_desc: billline.line_desc
},
cm: { ...cm, billlines: { data: cm.billlines } },
pol: {
returnfrombill: billid,
vendorid: bodyshop.inhousevendorid,
deliver_by: dayjs().format("YYYY-MM-DD"),
parts_order_lines: {
data: [
{
line_desc: billline.line_desc,
act_price: billline.actual_price,
cost: billline.actual_cost,
quantity: billline.quantity,
job_line_id: billline.joblineid === "noline" ? null : billline.joblineid,
part_type: billline.jobline && billline.jobline.part_type,
cm_received: true
}
]
},
order_date: "2022-06-01",
orderedby: currentUser.email,
jobid: jobid,
user_email: currentUser.email,
return: true,
status: "Ordered"
}
},
refetchQueries: ["QUERY_BILL_BY_PK"]
});
if (!insertResult?.errors?.length) {
notification.success({
title: t("inventory.successes.inserted")
});
} else {
notification.error({
title: t("inventory.errors.inserting", {
error: JSON.stringify(insertResult.errors)
})
});
}
} catch (err) {
notification.error({
title: t("inventory.errors.inserting", {
error: err?.message || String(err)
})
});
} finally {
setLoading(false);
}
};
return (
<Tooltip title={t("inventory.actions.addtoinventory")}>
<Button
icon={<FileAddFilled />}
loading={loading}
disabled={disabled || billline?.inventories?.length >= billline.quantity}
disabled={disabled || inventoryCount >= quantity}
onClick={addToInventory}
>
<FileAddFilled />
{billline?.inventories?.length > 0 && <div>({billline?.inventories?.length} in inv)</div>}
{inventoryCount > 0 && <div>({inventoryCount} in inv)</div>}
</Button>
</Tooltip>
);

View File

@@ -84,15 +84,14 @@ export function BillsListTableComponent({
}
});
}}
>
<FaTasks />
</Button>
icon={<FaTasks />}
/>
<BillDeleteButton bill={record} jobid={job.id} />
<BillDetailEditReturnComponent
data={{ bills_by_pk: { ...record, jobid: job.id, job: job } }}
disabled={record.is_credit_memo || record.vendorid === bodyshop.inhousevendorid || jobRO}
/>
{record.isinhouse && (
<PrintWrapperComponent
templateObject={{
@@ -190,9 +189,7 @@ export function BillsListTableComponent({
title={t("bills.labels.bills")}
extra={
<Space wrap>
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
{job && job.converted ? (
<>
<Button
@@ -235,6 +232,7 @@ export function BillsListTableComponent({
e.preventDefault();
setSearchText(e.target.value);
}}
enterButton
/>
</Space>
}

View File

@@ -41,9 +41,7 @@ export default function CABCpvrtCalculator({ disabled, form }) {
return (
<Popover destroyOnHidden content={popContent} open={visibility} disabled={disabled}>
<Button disabled={disabled} onClick={() => setVisibility(true)}>
<CalculatorFilled />
</Button>
<Button disabled={disabled} onClick={() => setVisibility(true)} icon={<CalculatorFilled />} />
</Popover>
);
}

View File

@@ -12,11 +12,16 @@ export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) {
const client = useApolloClient();
const { socket } = useSocket();
// 1) FCM subscription (independent of socket handler registration)
useEffect(() => {
if (!bodyshop?.messagingservicesid) return;
const messagingServicesId = bodyshop?.messagingservicesid;
const bodyshopId = bodyshop?.id;
const imexshopid = bodyshop?.imexshopid;
async function subscribeToTopicForFCMNotification() {
const messagingEnabled = Boolean(messagingServicesId);
useEffect(() => {
if (!messagingEnabled) return;
(async () => {
try {
await requestForToken();
await axios.post("/notifications/subscribe", {
@@ -24,23 +29,19 @@ export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) {
vapidKey: import.meta.env.VITE_APP_FIREBASE_PUBLIC_VAPID_KEY
}),
type: "messaging",
imexshopid: bodyshop.imexshopid
imexshopid
});
} catch (error) {
console.log("Error attempting to subscribe to messaging topic: ", error);
}
}
})();
}, [messagingEnabled, imexshopid]);
subscribeToTopicForFCMNotification();
}, [bodyshop?.messagingservicesid, bodyshop?.imexshopid]);
// 2) Register socket handlers as soon as socket is connected (regardless of chatVisible)
useEffect(() => {
if (!socket) return;
if (!bodyshop?.messagingservicesid) return;
if (!bodyshop?.id) return;
if (!messagingEnabled) return;
if (!bodyshopId) return;
// If socket isn't connected yet, ensure no stale handlers remain.
if (!socket.connected) {
unregisterMessagingHandlers({ socket });
return;
@@ -56,16 +57,14 @@ export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) {
bodyshop
});
return () => {
unregisterMessagingHandlers({ socket });
};
}, [socket, socket?.connected, bodyshop?.id, bodyshop?.messagingservicesid, client, currentUser?.email]);
return () => unregisterMessagingHandlers({ socket });
}, [socket, messagingEnabled, bodyshopId, client, currentUser?.email, bodyshop]);
if (!bodyshop?.messagingservicesid) return <></>;
if (!messagingEnabled) return null;
return (
<div className={`chat-affix ${chatVisible ? "chat-affix-open" : ""}`}>
{bodyshop?.messagingservicesid ? <ChatPopupComponent /> : null}
{messagingEnabled ? <ChatPopupComponent /> : null}
</div>
);
}

View File

@@ -128,7 +128,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
onClick={() => setSelectedConversation(item.id)}
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
>
<Card style={getCardStyle()} variant={true} size="small" extra={cardExtra} title={cardTitle}>
<Card style={getCardStyle()} variant="outlined" size="small" extra={cardExtra} title={cardTitle}>
<div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div>
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
</Card>

View File

@@ -1,22 +1,23 @@
import { Button } from "antd";
import parsePhoneNumber from "libphonenumber-js";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { createStructuredSelector } from "reselect";
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { searchingForConversation } from "../../redux/messaging/messaging.selectors";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
searchingForConversation: searchingForConversation
searchingForConversation
});
const mapDispatchToProps = (dispatch) => ({
openChatByPhone: (phone) => dispatch(openChatByPhone(phone))
openChatByPhone: (payload) => dispatch(openChatByPhone(payload))
});
export function ChatOpenButton({ bodyshop, searchingForConversation, phone, type, jobid, openChatByPhone }) {
@@ -24,31 +25,59 @@ export function ChatOpenButton({ bodyshop, searchingForConversation, phone, type
const { socket } = useSocket();
const notification = useNotification();
if (!phone) return <></>;
if (!phone) return null;
if (!bodyshop.messagingservicesid) {
return <PhoneNumberFormatter type={type}>{phone}</PhoneNumberFormatter>;
}
const messagingEnabled = Boolean(bodyshop?.messagingservicesid);
const parsed = useMemo(() => {
if (!messagingEnabled) return null;
try {
return parsePhoneNumber(phone, "CA") || null;
} catch {
return null;
}
}, [messagingEnabled, phone]);
const isValid = Boolean(parsed?.isValid?.() && parsed.isValid());
const clickable = messagingEnabled && !searchingForConversation && isValid;
const onClick = useCallback(
(e) => {
e.preventDefault();
e.stopPropagation();
if (!messagingEnabled) return;
if (searchingForConversation) return;
if (!isValid) {
notification.error({ title: t("messaging.error.invalidphone") });
return;
}
openChatByPhone({
phone_num: parsed.formatInternational(),
jobid,
socket
});
},
[messagingEnabled, searchingForConversation, isValid, parsed, jobid, socket, openChatByPhone, notification, t]
);
const content = <PhoneNumberFormatter type={type}>{phone}</PhoneNumberFormatter>;
// If not clickable, render plain formatted text (no link styling)
if (!clickable) return content;
// Clickable: render as a link-styled button (best for a “command”)
return (
<a
href="# "
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (searchingForConversation) return; // Prevent finding the same thing twice.
const p = parsePhoneNumber(phone, "CA");
if (p && p.isValid()) {
openChatByPhone({ phone_num: p.formatInternational(), jobid, socket });
} else {
notification.error({ title: t("messaging.error.invalidphone") });
}
}}
<Button
type="link"
onClick={onClick}
className="chat-open-button-link"
aria-label={t("messaging.actions.openchat") || "Open chat"}
>
<PhoneNumberFormatter type={type}>{phone}</PhoneNumberFormatter>
</a>
{content}
</Button>
);
}

View File

@@ -15,17 +15,19 @@ import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import "./chat-popup.styles.scss";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { selectDarkMode } from "../../redux/application/application.selectors.js";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,
chatVisible: selectChatVisible
chatVisible: selectChatVisible,
isDarkMode: selectDarkMode
});
const mapDispatchToProps = (dispatch) => ({
toggleChatVisible: () => dispatch(toggleChatVisible())
});
export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) {
export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible, isDarkMode }) {
const { t } = useTranslation();
const { socket } = useSocket();
const client = useApolloClient();
@@ -105,17 +107,20 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
hasLoadedConversationsOnceRef.current = true;
getConversations({ offset: 0 }).catch((err) => {
console.error(`Error fetching conversations: ${err?.message || ""}`, err);
getConversations({ variables: { offset: 0 } }).catch((err) => {
// Ignore abort errors (they're expected when component unmounts)
if (err?.name !== "AbortError") {
console.error(`Error fetching conversations: ${err?.message || ""}`, err);
}
});
}, [getConversations]);
}, []);
const handleManualRefresh = async () => {
try {
if (called && typeof refetch === "function") {
await refetch({ variables: { offset: 0 } });
await refetch({ offset: 0 });
} else {
await getConversations({ offset: 0 });
await getConversations({ variables: { offset: 0 } });
}
} catch (err) {
console.error(`Error refreshing conversations: ${err?.message || ""}`, err);
@@ -154,7 +159,7 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
<Badge count={unreadCount}>
<Card size="small">
{chatVisible ? (
<div className="chat-popup">
<div className={`chat-popup ${isDarkMode ? "chat-popup--dark" : "chat-popup--light"}`}>
<Space align="center">
<Typography.Title level={4}>{t("messaging.labels.messaging")}</Typography.Title>
<ChatNewConversation />

View File

@@ -26,3 +26,11 @@
flex-direction: column;
}
}
.chat-popup--dark {
color-scheme: dark;
}
.chat-popup--light {
color-scheme: light;
}

View File

@@ -28,7 +28,7 @@ export function ChatTagRoContainer({ conversation, bodyshop }) {
const executeSearch = (v) => {
logImEXEvent("messaging_search_job_tag", { searchTerm: v });
loadRo(v).catch((e) => console.error("Error in ChatTagRoContainer executeSearch:", e));
loadRo({ variables: v }).catch((e) => console.error("Error in ChatTagRoContainer executeSearch:", e));
};
const debouncedExecuteSearch = _.debounce(executeSearch, 500);

View File

@@ -99,6 +99,7 @@ export default function ContractsCarsComponent({ loading, data, selectedCarId, h
placeholder={t("general.labels.search")}
value={state.search}
onChange={(e) => setState({ ...state, search: e.target.value })}
enterButton
/>
}
>

View File

@@ -11,7 +11,7 @@ export default function ContractCreateJobPrefillComponent({ jobId, form }) {
const notification = useNotification();
const handleClick = () => {
call({ id: jobId });
call({ variables: { id: jobId } });
};
useEffect(() => {

View File

@@ -1,5 +1,5 @@
import { WarningFilled } from "@ant-design/icons";
import { Form, Input, InputNumber, Space } from "antd";
import { Card, Form, Input, InputNumber, Space } from "antd";
import { useTranslation } from "react-i18next";
import { DateFormatter } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
@@ -19,9 +19,9 @@ import ContractFormJobPrefill from "./contract-form-job-prefill.component";
export default function ContractFormComponent({ form, create = false, selectedJobState, selectedCar }) {
const { t } = useTranslation();
return (
<>
<Card>
{!create && <FormFieldsChanged form={form} />}
<LayoutFormRow>
<LayoutFormRow noDivider={true}>
{!create && (
<Form.Item
label={t("contracts.fields.status")}
@@ -310,6 +310,6 @@ export default function ContractFormComponent({ form, create = false, selectedJo
<InputNumber precision={2} />
</Form.Item>
</LayoutFormRow>
</>
</Card>
);
}

View File

@@ -123,6 +123,7 @@ export default function ContractsJobsComponent({ loading, data, selectedJob, han
placeholder={t("general.labels.search")}
value={state.search}
onChange={(e) => setState({ ...state, search: e.target.value })}
enterButton
/>
}
>

View File

@@ -35,8 +35,10 @@ export function ContractsFindModalContainer({ contractFinderModal, toggleModalVi
//Execute contract find
callSearch({
plate: (values.plate && values.plate !== "" && values.plate) || undefined,
time: values.time
variables: {
plate: (values.plate && values.plate !== "" && values.plate) || undefined,
time: values.time
}
});
};

View File

@@ -156,15 +156,15 @@ export function ContractsList({ bodyshop, loading, contracts, refetch, total, se
</>
)}
<Button onClick={() => setContractFinderContext()}>{t("contracts.actions.find")}</Button>
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Input.Search
placeholder={search.searh || t("general.labels.search")}
onSearch={(value) => {
const updatedSearch = { ...search, search: value };
history({ search: queryString.stringify(updatedSearch) });
}}
enterButton
/>
</Space>
}

View File

@@ -1,6 +1,6 @@
import { WarningFilled } from "@ant-design/icons";
import { useApolloClient } from "@apollo/client/react";
import { Button, Form, Input, InputNumber, Space } from "antd";
import { Button, Card, Form, Input, InputNumber, Space } from "antd";
import { PageHeader } from "@ant-design/pro-layout";
import dayjs from "../../utils/day";
import { useTranslation } from "react-i18next";
@@ -19,7 +19,7 @@ export default function CourtesyCarCreateFormComponent({ form, saveLoading, newC
const client = useApolloClient();
return (
<div>
<Card>
<PageHeader
title={t("menus.header.courtesycars")}
extra={
@@ -314,6 +314,6 @@ export default function CourtesyCarCreateFormComponent({ form, saveLoading, newC
<CurrencyInput />
</Form.Item>
</LayoutFormRow>
</div>
</Card>
);
}

View File

@@ -255,9 +255,8 @@ export default function CourtesyCarsList({ loading, courtesycars, refetch }) {
title={t("menus.header.courtesycars")}
extra={
<Space wrap>
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Dropdown trigger="click" menu={menu}>
<Button>{t("general.labels.print")}</Button>
</Dropdown>

View File

@@ -85,13 +85,7 @@ export default function CsiResponseListPaginated({ refetch, loading, responses,
};
return (
<Card
extra={
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
}
>
<Card extra={<Button onClick={() => refetch()} icon={<SyncOutlined />} />}>
<Table
loading={loading}
pagination={{ placement: "top", pageSize: pageLimit, current: parseInt(state.page || 1), total: total }}

View File

@@ -96,6 +96,7 @@ export default function DashboardMonthlyJobCosting({ data, ...cardProps }) {
e.preventDefault();
setSearchText(e.target.value);
}}
enterButton
/>
</Space>
}

View File

@@ -196,9 +196,7 @@ export function DashboardGridComponent({ currentUser }) {
<PageHeader
extra={
<Space>
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Dropdown menu={menu} trigger={["click"]}>
<Button>{t("dashboard.actions.addcomponent")}</Button>
</Dropdown>

View File

@@ -13,31 +13,32 @@ export default function DataLabel({
if (!open || (hideIfNull && !children)) return null;
return (
<div {...props} style={{ display: "flex" }}>
<div {...props} style={{ display: "flex", alignItems: "flex-start" }}>
<div
style={{
// flex: 2,
marginRight: ".2rem"
marginRight: ".2rem",
flexShrink: 0, // <-- key: don't let the label collapse
whiteSpace: "nowrap" // <-- key: keep "Email:" on one line
}}
>
<Typography.Text type="secondary">{`${label}:`}</Typography.Text>
</div>
<div
style={{
flex: 4,
flex: 1, // <-- key: take remaining space
minWidth: 0, // <-- key: allow this flex item to shrink
marginLeft: ".3rem",
fontWeight: "bolder",
wordWrap: "break-word",
cursor: onValueClick !== undefined ? "pointer" : ""
overflowWrap: "anywhere", // <-- key: break long tokens (email/vin)
wordBreak: "break-word", // (backup behavior across browsers)
cursor: onValueClick !== undefined ? "pointer" : "",
...(styles?.value ?? {}) // apply your per-field overrides to ALL children types
}}
className={valueClassName}
onClick={onValueClick}
>
{typeof children === "string" ? (
<Typography.Text style={styles?.value}>{children}</Typography.Text>
) : (
children
)}
{typeof children === "string" ? <Typography.Text>{children}</Typography.Text> : children}
</div>
</div>
);

View File

@@ -1,6 +1,6 @@
import { SyncOutlined } from "@ant-design/icons";
import { Button, Card, Form, Input, Table } from "antd";
import { useEffect, useState, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -111,9 +111,8 @@ export function DmsAllocationsSummaryAp({ socket, bodyshop, billids, title }) {
onClick={() => {
socket.emit("pbs-calculate-allocations-ap", billids, (ack) => setAllocationsSummary(ack));
}}
>
<SyncOutlined />
</Button>
icon={<SyncOutlined />}
/>
}
>
<Table

View File

@@ -1,6 +1,6 @@
import { Alert, Button, Card, Table, Typography } from "antd";
import { SyncOutlined } from "@ant-design/icons";
import { useCallback, useEffect, useState, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -110,11 +110,7 @@ export function DmsAllocationsSummary({ mode, socket, bodyshop, jobId, title, on
return (
<Card
title={title}
extra={
<Button onClick={fetchAllocations} aria-label={t("general.actions.refresh")}>
<SyncOutlined />
</Button>
}
extra={<Button onClick={fetchAllocations} aria-label={t("general.actions.refresh")} icon={<SyncOutlined />} />}
>
{bodyshop.pbs_configuration?.disablebillwip && (
<Alert type="warning" title={t("jobs.labels.dms.disablebillwip")} />

View File

@@ -1,6 +1,6 @@
import { Alert, Button, Card, Table, Tabs, Typography } from "antd";
import { SyncOutlined } from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -329,11 +329,7 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
return (
<Card
title={title}
extra={
<Button onClick={fetchAllocations} aria-label={t("general.actions.refresh")}>
<SyncOutlined />
</Button>
}
extra={<Button onClick={fetchAllocations} aria-label={t("general.actions.refresh")} icon={<SyncOutlined />} />}
>
{bodyshop.pbs_configuration?.disablebillwip && (
<Alert type="warning" title={t("jobs.labels.dms.disablebillwip")} />

View File

@@ -49,19 +49,23 @@ export function DmsCdkVehicles({ form, job }) {
open={open}
onCancel={() => setOpen(false)}
onOk={() => {
form.setFieldsValue({
dms_make: selectedModel.makecode,
dms_model: selectedModel.modelcode
});
setOpen(false);
if (selectedModel) {
form.setFieldsValue({
dms_make: selectedModel.makecode,
dms_model: selectedModel.modelcode
});
setOpen(false);
}
}}
okButtonProps={{ disabled: !selectedModel }}
>
{error && <AlertComponent title={error.message} type="error" />}
<Table
title={() => (
<Input.Search
onSearch={(val) => callSearch({ search: val })}
onSearch={(val) => callSearch({ variables: { search: val } })}
placeholder={t("general.labels.search")}
enterButton
/>
)}
columns={columns}
@@ -87,7 +91,9 @@ export function DmsCdkVehicles({ form, job }) {
onClick={() => {
setOpen(true);
callSearch({
search: job?.v_model_desc && job.v_model_desc.substr(0, 3)
variables: {
search: job?.v_model_desc && job.v_model_desc.substr(0, 3)
}
});
}}
>

View File

@@ -53,13 +53,13 @@ export default function FortellisCustomerSelector({ bodyshop, jobid, socket }) {
render: (_t, r) => <Checkbox disabled checked={r.vinOwner} />
},
{
title: t("jobs.fields.dms.name1"),
title: t("jobs.fields.dms.first_name"),
dataIndex: ["customerName", "firstName"],
key: "firstName",
sorter: (a, b) => alphaSort(a.customerName?.firstName, b.customerName?.firstName)
},
{
title: t("jobs.fields.dms.name1"),
title: t("jobs.fields.dms.last_name"),
dataIndex: ["customerName", "lastName"],
key: "lastName",
sorter: (a, b) => alphaSort(a.customerName?.lastName, b.customerName?.lastName)

View File

@@ -272,7 +272,7 @@ export default function CdkLikePostForm({ bodyshop, socket, job, logsRef, mode,
name={[field.name, "name"]}
rules={[{ required: true }]}
>
<Select style={{ minWidth: "15rem" }} onSelect={(value) => handlePayerSelect(value, index)}>
<Select style={{ width: "100%" }} onSelect={(value) => handlePayerSelect(value, index)}>
{bodyshop.cdk_configuration?.payers?.map((payer) => (
<Select.Option key={payer.name}>{payer.name}</Select.Option>
))}

View File

@@ -32,6 +32,7 @@ export function EmailOverlayContainer({ emailConfig, modalVisible, toggleEmailOv
const [loading, setLoading] = useState(false);
const [sending, setSending] = useState(false);
const [rawHtml, setRawHtml] = useState("");
const [htmlSize, setHtmlSize] = useState(0);
const [pdfCopytoAttach, setPdfCopytoAttach] = useState({
filename: null,
pdf: null
@@ -151,6 +152,13 @@ export function EmailOverlayContainer({ emailConfig, modalVisible, toggleEmailOv
if (modalVisible) render();
}, [modalVisible]);
useEffect(() => {
const html = form.getFieldValue("html");
if (html) {
setHtmlSize(new Blob([html]).size);
}
}, [form, rawHtml]);
return (
<Modal
destroyOnHidden
@@ -169,7 +177,7 @@ export function EmailOverlayContainer({ emailConfig, modalVisible, toggleEmailOv
disabled:
selectedMedia &&
(selectedMedia.filter((s) => s.isSelected).reduce((acc, val) => (acc = acc + val.size), 0) >=
10485760 - new Blob([form.getFieldValue("html")]).size ||
10485760 - htmlSize ||
selectedMedia.filter((s) => s.isSelected).length > 10)
}}
>
@@ -195,7 +203,7 @@ export function EmailOverlayContainer({ emailConfig, modalVisible, toggleEmailOv
disabled={
selectedMedia &&
(selectedMedia.filter((s) => s.isSelected).reduce((acc, val) => (acc = acc + val.size), 0) >=
10485760 - new Blob([form.getFieldValue("html")]).size ||
10485760 - htmlSize ||
selectedMedia.filter((s) => s.isSelected).length > 10)
}
type="primary"

View File

@@ -53,9 +53,7 @@ export default function InventoryLineDelete({ inventoryline, disabled, refetch }
onConfirm={handleDelete}
title={t("inventory.labels.deleteconfirm")}
>
<Button disabled={disabled || inventoryline.consumedbybillid} loading={loading}>
<DeleteFilled />
</Button>
<Button disabled={disabled || inventoryline.consumedbybillid} loading={loading} icon={<DeleteFilled />} />
</Popconfirm>
</RbacWrapper>
);

View File

@@ -110,9 +110,9 @@ export function JobsList({ refetch, loading, jobs, total, setInventoryUpsertCont
}
});
}}
>
<EditFilled />
</Button>
icon={<EditFilled />}
/>
<InventoryLineDelete inventoryline={record} refetch={refetch} />
</Space>
)
@@ -155,9 +155,9 @@ export function JobsList({ refetch, loading, jobs, total, setInventoryUpsertCont
context: {}
});
}}
>
<FileAddFilled />
</Button>
icon={<FileAddFilled />}
/>
<Button
onClick={() => {
const updatedSearch = { ...search };
@@ -172,9 +172,8 @@ export function JobsList({ refetch, loading, jobs, total, setInventoryUpsertCont
{search.showall ? t("inventory.labels.showavailable") : t("inventory.labels.showall")}
</Button>
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Input.Search
placeholder={search.search || t("general.labels.search")}
onSearch={(value) => {

View File

@@ -120,6 +120,13 @@ export function ScheduleEventComponent({
);
const handleConvert = async (values) => {
if (!event.job?.id) {
notification.error({
title: t("appointments.errors.nojob")
});
return;
}
const res = await mutationUpdateJob({
variables: {
jobId: event.job.id,
@@ -397,21 +404,21 @@ export function ScheduleEventComponent({
(HasFeatureAccess({ featureName: "checklist", bodyshop }) ? (
<Link
to={{
pathname: `/manage/jobs/${event.job && event.job.id}/intake`,
pathname: `/manage/jobs/${event.job.id}/intake`,
search: `?appointmentId=${event.id}`
}}
>
<Button disabled={event.arrived}>{t("appointments.actions.intake")}</Button>
</Link>
) : (
<Popover //open={open}
<Popover
content={popMenu}
open={popOverVisible}
onOpenChange={setPopOverVisible}
onClick={(e) => {
if (event.job?.id) {
e.stopPropagation();
getJobDetails({ id: event.job.id });
getJobDetails({ variables: { id: event.job.id } });
}
}}
getPopupContainer={(trigger) => trigger.parentNode}
@@ -434,37 +441,36 @@ export function ScheduleEventComponent({
return baseColor;
};
const RegularEvent = event.isintake ? (
<Space
wrap
size="small"
style={{
backgroundColor: getEventBackground()
}}
>
{event.note && <AlertFilled className="production-alert" />}
<strong>{`${event.job.ro_number || t("general.labels.na")}`}</strong>
<OwnerNameDisplay ownerObject={event.job} />
{`${(event.job && event.job.v_model_yr) || ""} ${
(event.job && event.job.v_make_desc) || ""
} ${(event.job && event.job.v_model_desc) || ""}`}
{`(${(event.job && event.job.labhrs.aggregate.sum.mod_lb_hrs) || "0"} / ${
(event.job && event.job.larhrs.aggregate.sum.mod_lb_hrs) || "0"
})`}
{event.job && event.job.alt_transport && <div style={{ margin: ".1rem" }}>{event.job.alt_transport}</div>}
{event?.job?.comment && `C: ${event.job.comment}`}
</Space>
) : (
<div
style={{
height: "100%",
width: "100%",
backgroundColor: getEventBackground()
}}
>
<strong>{`${event.title || ""}`}</strong>
</div>
);
const RegularEvent =
event.isintake && event.job ? (
<Space
wrap
size="small"
style={{
backgroundColor: getEventBackground()
}}
>
{event.note && <AlertFilled className="production-alert" />}
<strong>{`${event.job.ro_number || t("general.labels.na")}`}</strong>
<OwnerNameDisplay ownerObject={event.job} />
{`${event.job.v_model_yr || ""} ${event.job.v_make_desc || ""} ${event.job.v_model_desc || ""}`}
{`(${event.job.labhrs?.aggregate?.sum?.mod_lb_hrs || "0"} / ${
event.job.larhrs?.aggregate?.sum?.mod_lb_hrs || "0"
})`}
{event.job.alt_transport && <div style={{ margin: ".1rem" }}>{event.job.alt_transport}</div>}
{event.job.comment && `C: ${event.job.comment}`}
</Space>
) : (
<div
style={{
height: "100%",
width: "100%",
backgroundColor: getEventBackground()
}}
>
<strong>{`${event.title || ""}`}</strong>
</div>
);
return (
<Popover

View File

@@ -61,9 +61,7 @@ export function ScheduleEventNote({ event }) {
) : (
<Input.TextArea rows={3} value={note} onChange={(e) => setNote(e.target.value)} style={{ maxWidth: "8vw" }} />
)}
<Button onClick={toggleEdit} loading={loading}>
{editing ? <SaveFilled /> : <EditFilled />}
</Button>
<Button onClick={toggleEdit} loading={loading} icon={editing ? <SaveFilled /> : <EditFilled />} />
</Space>
</DataLabel>
);

View File

@@ -159,9 +159,8 @@ export function JobAuditTrail({ bodyshop, jobId }) {
onClick={() => {
refetch();
}}
>
<SyncOutlined />
</Button>
icon={<SyncOutlined />}
/>
}
>
<Table loading={loading} columns={columns} rowKey="id" dataSource={data ? data.audit_trail : []} />

View File

@@ -18,29 +18,6 @@ export default function JobCalculateTotals({ job, disabled, refetch }) {
});
if (refetch) refetch();
// const result = await updateJob({
// refetchQueries: ["GET_JOB_BY_PK"],
// awaitRefetchQueries: true,
// variables: {
// jobId: job.id,
// job: {
// job_totals: newTotals,
// clm_total: Dinero(newTotals.totals.total_repairs).toFormat("0.00"),
// owner_owing: Dinero(newTotals.totals.custPayable.total).toFormat(
// "0.00"
// ),
// },
// },
// });
// if (!!!result.errors) {
// notification["success"]({ message: t("jobs.successes.updated") });
// } else {
// notification.error({
// message: t("jobs.errors.updating", {
// error: JSON.stringify(result.errors),
// }),
// });
// }
} catch (error) {
notification.error({
title: t("jobs.errors.updating", {

View File

@@ -61,9 +61,12 @@ export default function JobIntakeTemplateList({ templates }) {
renderItem={(template) => (
<List.Item
actions={[
<Button key="checkListTemplateButton" loading={loading} onClick={() => renderTemplate(template)}>
<PrinterFilled />
</Button>
<Button
key="checkListTemplateButton"
loading={loading}
onClick={() => renderTemplate(template)}
icon={<PrinterFilled />}
/>
]}
>
<List.Item.Meta

View File

@@ -16,11 +16,15 @@ const mapDispatchToProps = () => ({});
export default connect(mapStateToProps, mapDispatchToProps)(JobCloseRoGuardLabor);
export function JobCloseRoGuardLabor({ job, bodyshop, warningCallback }) {
const jobId = job?.id ?? null;
const { loading, error, data, refetch } = useQuery(GET_LINE_TICKET_BY_PK, {
variables: { id: job.id },
variables: { id: jobId },
skip: !jobId,
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
const {
treatments: { Enhanced_Payroll }
} = useTreatmentsWithConfig({
@@ -29,12 +33,13 @@ export function JobCloseRoGuardLabor({ job, bodyshop, warningCallback }) {
splitKey: bodyshop.imexshopid
});
if (!jobId) return <LoadingSkeleton />;
if (loading) return <LoadingSkeleton />;
if (error) return <AlertComponent title={error.message} type="error" />;
return Enhanced_Payroll.treatment === "on" ? (
<PayrollLaborAllocationsTable
jobId={job.id}
jobId={jobId}
timetickets={data ? data.timetickets : []}
refetch={refetch}
adjustments={data ? data.jobs_by_pk.lbr_adjustments : []}
@@ -43,7 +48,7 @@ export function JobCloseRoGuardLabor({ job, bodyshop, warningCallback }) {
/>
) : (
<LaborAllocationsTableComponent
jobId={job.id}
jobId={jobId}
joblines={data ? data.joblines : []}
timetickets={data ? data.timetickets : []}
refetch={refetch}

View File

@@ -76,6 +76,7 @@ export default function JobCostingPartsTable({ data, summaryData }) {
e.preventDefault();
setSearchText(e.target.value);
}}
enterButton
/>
</Space>
);

View File

@@ -50,7 +50,8 @@ export function JobCreateIOU({ bodyshop, currentUser, job, selectedJobLines, tec
config: {
status: bodyshop.md_ro_statuses.default_open,
bodyshopid: bodyshop.id,
useremail: currentUser.email
useremail: currentUser.email,
timezone: bodyshop.timezone
},
currentUser
});

View File

@@ -395,9 +395,8 @@ export function JobLinesComponent({
context: { ...record, jobid: job.id }
});
}}
>
<EditFilled />
</Button>
icon={<EditFilled />}
/>
)}
<Button
title={t("tasks.buttons.create")}
@@ -409,9 +408,9 @@ export function JobLinesComponent({
}
});
}}
>
<FaTasks />
</Button>
icon={<FaTasks />}
/>
{(record.manual_line || jobIsPrivate) && !technician && (
<Button
disabled={jobRO}
@@ -431,9 +430,8 @@ export function JobLinesComponent({
await axios.post("/job/totalsssu", { id: job.id });
if (refetch) refetch();
}}
>
<DeleteFilled />
</Button>
icon={<DeleteFilled />}
/>
)}
</Space>
)
@@ -542,9 +540,7 @@ export function JobLinesComponent({
title={t("jobs.labels.estimatelines")}
extra={
<Space wrap>
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
{/* Bulk Update Location */}
<Button
@@ -609,8 +605,8 @@ export function JobLinesComponent({
setSelectedLines([]);
}}
icon={<HomeOutlined />}
>
<HomeOutlined />
{t("parts.actions.orderinhouse")}
{selectedLines.length > 0 && ` (${selectedLines.length})`}
</Button>
@@ -641,6 +637,7 @@ export function JobLinesComponent({
{!isPartsEntry && (
<Button
icon={<FilterFilled />}
id="job-lines-filter-parts-only-button"
onClick={() => {
setState((state) => ({
@@ -652,7 +649,7 @@ export function JobLinesComponent({
}));
}}
>
<FilterFilled /> {t("jobs.actions.filterpartsonly")}
{t("jobs.actions.filterpartsonly")}
</Button>
)}
@@ -685,6 +682,7 @@ export function JobLinesComponent({
e.preventDefault();
setSearchText(e.target.value);
}}
enterButton
/>
</Space>
}

View File

@@ -13,11 +13,22 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
jobRO: selectJobReadOnly
});
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
const mapDispatchToProps = () => ({});
const iconStyle = { marginLeft: ".3rem" };
const iconStyle = {
marginLeft: ".3rem"
};
const iconClickableStyle = {
marginLeft: ".3rem",
cursor: "pointer"
};
const iconDisabledStyle = {
marginLeft: ".3rem",
cursor: "not-allowed",
opacity: 0.5
};
export function JobEmployeeAssignments({
bodyshop,
@@ -31,163 +42,199 @@ export function JobEmployeeAssignments({
loading
}) {
const { t } = useTranslation();
const [assignment, setAssignment] = useState({
operation: null,
employeeid: null
});
const [visibility, setVisibility] = useState(false);
const onChange = (value, option) => {
setAssignment({ ...assignment, employeeid: value, name: option.name });
// Which assignment popover is currently open: "body" | "prep" | "refinish" | "csr" | null
const [openOperation, setOpenOperation] = useState(null);
// Current selection inside the popover
const [selected, setSelected] = useState({ employeeid: null, name: null });
const employeeOptions = (bodyshop?.employees || [])
.filter((emp) => emp.active)
.map((emp) => ({
value: emp.id,
label: `${emp.first_name} ${emp.last_name}`
}));
const getPopupContainer = () => document.querySelector("#time-ticket-modal") || document.body;
const openFor = (operation) => {
if (jobRO) return;
setSelected({ employeeid: null, name: null });
setOpenOperation(operation);
};
const popContent = (
<Row gutter={[16, 16]}>
<Col span={24}>
<Select
id="employeeSelector"
showSearc={{
optionFilterProp: "children",
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}}
style={{ width: 200 }}
onChange={onChange}
>
{bodyshop.employees
.filter((emp) => emp.active)
.map((emp) => (
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}>
{`${emp.first_name} ${emp.last_name}`}
</Select.Option>
))}
</Select>
</Col>
<Col span={24}>
<Space wrap>
<Button
type="primary"
disabled={!assignment.employeeid || jobRO}
onClick={() => {
handleAdd(assignment);
setVisibility(false);
const close = () => {
setOpenOperation(null);
setSelected({ employeeid: null, name: null });
};
const renderAssigner = (operation) => {
if (jobRO) {
return <PlusCircleFilled style={iconDisabledStyle} />;
}
const popContent = (
<Row gutter={[16, 16]}>
<Col span={24}>
<Select
style={{ width: 220 }}
options={employeeOptions}
value={selected.employeeid}
placeholder={t("employees.actions.select")}
allowClear
showSearch={{
optionFilterProp: "label"
}}
>
{t("allocations.actions.assign")}
</Button>
<Button onClick={() => setVisibility(false)}>Close</Button>
</Space>
</Col>
</Row>
);
onChange={(value, option) => {
if (!value) {
setSelected({ employeeid: null, name: null });
return;
}
setSelected({ employeeid: value, name: option?.label || null });
}}
/>
</Col>
<Col span={24}>
<Space wrap>
<Button
type="primary"
disabled={!selected.employeeid}
onClick={() => {
handleAdd({ operation, employeeid: selected.employeeid, name: selected.name });
close();
}}
>
{t("allocations.actions.assign")}
</Button>
<Button onClick={close}>Close</Button>
</Space>
</Col>
</Row>
);
return (
<Popover
destroyOnHidden
trigger="click"
open={openOperation === operation}
onOpenChange={(open) => {
// Important: let Popover drive close on outside click
if (open) openFor(operation);
else close();
}}
content={popContent}
getPopupContainer={getPopupContainer}
>
<span
role="button"
tabIndex={0}
style={{ display: "inline-flex", alignItems: "center", cursor: "pointer" }}
onClick={(e) => {
// Prevent the click from being re-interpreted as "outside"
e.preventDefault();
e.stopPropagation();
openFor(operation);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openFor(operation);
}
}}
>
<PlusCircleFilled style={iconClickableStyle} />
</span>
</Popover>
);
};
return (
<Popover destroyOnHidden content={popContent} open={visibility}>
<Spin spinning={loading}>
<DataLabel label={t("jobs.fields.employee_body")}>
{body ? (
<div>
<span>{`${body.first_name || ""} ${body.last_name || ""}`}</span>
<DeleteFilled
operation="body"
disabled={jobRO}
style={iconStyle}
onClick={() => !jobRO && handleRemove("body")}
/>
</div>
) : (
<PlusCircleFilled
<Spin spinning={loading}>
<DataLabel label={t("jobs.fields.employee_body")}>
{body ? (
<div>
<span>{`${body.first_name || ""} ${body.last_name || ""}`}</span>
<DeleteFilled
disabled={jobRO}
style={iconStyle}
onClick={() => {
if (!jobRO) {
setAssignment({ operation: "body" });
setVisibility(true);
}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!jobRO) handleRemove("body");
}}
/>
)}
</DataLabel>
<DataLabel label={t("jobs.fields.employee_prep")}>
{prep ? (
<div>
<span>{`${prep.first_name || ""} ${prep.last_name || ""}`}</span>
<DeleteFilled
disabled={jobRO}
style={iconStyle}
operation="prep"
onClick={() => !jobRO && handleRemove("prep")}
/>
</div>
) : (
<PlusCircleFilled
</div>
) : (
renderAssigner("body")
)}
</DataLabel>
<DataLabel label={t("jobs.fields.employee_prep")}>
{prep ? (
<div>
<span>{`${prep.first_name || ""} ${prep.last_name || ""}`}</span>
<DeleteFilled
disabled={jobRO}
style={iconStyle}
onClick={() => {
if (!jobRO) {
setAssignment({ operation: "prep" });
setVisibility(true);
}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!jobRO) handleRemove("prep");
}}
/>
)}
</DataLabel>
<DataLabel label={t("jobs.fields.employee_refinish")}>
{refinish ? (
<div>
<span>{`${refinish.first_name || ""} ${refinish.last_name || ""}`}</span>
<DeleteFilled
disabled={jobRO}
style={iconStyle}
operation="refinish"
onClick={() => !jobRO && handleRemove("refinish")}
/>
</div>
) : (
<PlusCircleFilled
</div>
) : (
renderAssigner("prep")
)}
</DataLabel>
<DataLabel label={t("jobs.fields.employee_refinish")}>
{refinish ? (
<div>
<span>{`${refinish.first_name || ""} ${refinish.last_name || ""}`}</span>
<DeleteFilled
disabled={jobRO}
style={iconStyle}
onClick={() => {
if (!jobRO) {
setAssignment({ operation: "refinish" });
setVisibility(true);
}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!jobRO) handleRemove("refinish");
}}
/>
)}
</DataLabel>
<DataLabel
label={t(
InstanceRenderManager({
imex: "jobs.fields.employee_csr",
rome: "jobs.fields.employee_csr_writer"
})
)}
>
{csr ? (
<div>
<span>{`${csr.first_name || ""} ${csr.last_name || ""}`}</span>
<DeleteFilled
disabled={jobRO}
style={iconStyle}
operation="csr"
onClick={() => !jobRO && handleRemove("csr")}
/>
</div>
) : (
<PlusCircleFilled
</div>
) : (
renderAssigner("refinish")
)}
</DataLabel>
<DataLabel
label={t(
InstanceRenderManager({
imex: "jobs.fields.employee_csr",
rome: "jobs.fields.employee_csr_writer"
})
)}
>
{csr ? (
<div>
<span>{`${csr.first_name || ""} ${csr.last_name || ""}`}</span>
<DeleteFilled
disabled={jobRO}
style={iconStyle}
onClick={() => {
if (!jobRO) {
setAssignment({ operation: "csr" });
setVisibility(true);
}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!jobRO) handleRemove("csr");
}}
/>
)}
</DataLabel>
</Spin>
</Popover>
</div>
) : (
renderAssigner("csr")
)}
</DataLabel>
</Spin>
);
}

View File

@@ -11,9 +11,7 @@ import { insertAuditTrail } from "../../redux/application/application.actions";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
});
const mapStateToProps = createStructuredSelector({});
const mapDispatchToProps = (dispatch) => ({
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
});
@@ -26,55 +24,69 @@ export function JobEmployeeAssignmentsContainer({ job, refetch, insertAuditTrail
const notification = useNotification();
const handleAdd = async (assignment) => {
setLoading(true);
const { operation, employeeid, name } = assignment;
logImEXEvent("job_assign_employee", { operation });
const empAssignment = determineFieldName(operation);
let empAssignment = determineFieldName(operation);
if (!job?.id || !empAssignment || !employeeid) return;
const result = await updateJob({
variables: { jobId: job.id, job: { [empAssignment]: employeeid } }
});
if (refetch) refetch();
if (!result.errors) {
insertAuditTrail({
jobid: job.id,
operation: AuditTrailMapping.jobassignmentchange(operation, name),
type: "jobassignmentchange"
});
} else {
notification.error({
title: t("jobs.errors.assigning", {
message: JSON.stringify(result.errors)
})
});
}
setLoading(false);
};
const handleRemove = async (operation) => {
setLoading(true);
logImEXEvent("job_unassign_employee", { operation });
try {
logImEXEvent("job_assign_employee", { operation });
let empAssignment = determineFieldName(operation);
const result = await updateJob({
variables: { jobId: job.id, job: { [empAssignment]: null } }
});
const result = await updateJob({
variables: { jobId: job.id, job: { [empAssignment]: employeeid } }
});
if (!result.errors) {
insertAuditTrail({
jobid: job.id,
operation: AuditTrailMapping.jobassignmentremoved(operation),
type: "jobassignmentremoved"
});
} else {
notification.error({
title: t("jobs.errors.assigning", {
message: JSON.stringify(result.errors)
})
});
if (typeof refetch === "function") await refetch();
if (!result.errors) {
insertAuditTrail({
jobid: job.id,
operation: AuditTrailMapping.jobassignmentchange(operation, name),
type: "jobassignmentchange"
});
} else {
notification.error({
title: t("jobs.errors.assigning", {
message: JSON.stringify(result.errors)
})
});
}
} finally {
setLoading(false);
}
};
const handleRemove = async (operation) => {
const empAssignment = determineFieldName(operation);
if (!job?.id || !empAssignment) return;
setLoading(true);
try {
logImEXEvent("job_unassign_employee", { operation });
const result = await updateJob({
variables: { jobId: job.id, job: { [empAssignment]: null } }
});
if (typeof refetch === "function") await refetch();
if (!result.errors) {
insertAuditTrail({
jobid: job.id,
operation: AuditTrailMapping.jobassignmentremoved(operation),
type: "jobassignmentremoved"
});
} else {
notification.error({
title: t("jobs.errors.assigning", {
message: JSON.stringify(result.errors)
})
});
}
} finally {
setLoading(false);
}
setLoading(false);
};
return (
@@ -102,7 +114,6 @@ const determineFieldName = (operation) => {
return "employee_csr";
case "refinish":
return "employee_refinish";
default:
return null;
}

View File

@@ -187,9 +187,8 @@ export function JobLineConvertToLabor({
loading={loading}
onClick={handleClick}
{...otherBtnProps}
>
<ClockCircleOutlined />
</Button>
icon={<ClockCircleOutlined />}
/>
</Tooltip>
</Popover>
)}

View File

@@ -107,9 +107,8 @@ export function JobPayments({ job, bodyshop, setPaymentContext, setCardPaymentCo
context: record
});
}}
>
<EditFilled />
</Button>
icon={<EditFilled />}
/>
<PrintWrapperComponent
templateObject={{
name: TemplateList("payment").payment_receipt.key,

View File

@@ -38,7 +38,7 @@ export function ScoreboardAddButton({ bodyshop, job, disabled, ...otherBtnProps
useEffect(() => {
if (visibility) {
callQuery({ jobid: job.id });
callQuery({ variables: { jobid: job.id } });
}
}, [visibility, job.id, callQuery]);

View File

@@ -11,8 +11,7 @@ export default function JobSyncButton({ job }) {
};
if (job?.available_jobs && job?.available_jobs?.length > 0)
return (
<Button onClick={handleClick}>
<SyncOutlined />
<Button onClick={handleClick} icon={<SyncOutlined />}>
{t("jobs.actions.sync")}
</Button>
);

View File

@@ -21,13 +21,16 @@ const colSpan = {
lg: { span: 12 }
};
export function JobsTotalsTableComponent({ jobRO, currentUser, job }) {
export function JobsTotalsTableComponent({ jobRO, currentUser, job, refetch }) {
const { t } = useTranslation();
if (!job.job_totals) {
return (
<Card>
<Result title={t("jobs.errors.nofinancial")} extra={<JobCalculateTotals job={job} disabled={jobRO} />} />
<Result
title={t("jobs.errors.nofinancial")}
extra={<JobCalculateTotals job={job} disabled={jobRO} refetch={refetch} />}
/>
</Card>
);
}
@@ -64,7 +67,7 @@ export function JobsTotalsTableComponent({ jobRO, currentUser, job }) {
{(currentUser.email.includes("@imex.") || currentUser.email.includes("@rome.")) && (
<Col span={24}>
<Card title="DEVELOPMENT USE ONLY">
<JobCalculateTotals job={job} disabled={jobRO} />
<JobCalculateTotals job={job} disabled={jobRO} refetch={refetch} />
<Collapse
items={[
{

View File

@@ -8,6 +8,8 @@ import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
const EMPTY_ARRAY = Object.freeze([]);
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
@@ -27,21 +29,24 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
const [selectedWatcher, setSelectedWatcher] = useState(null);
const [selectedTeam, setSelectedTeam] = useState(null);
const userEmail = currentUser.email;
const jobid = job.id;
const userEmail = currentUser?.email;
const jobid = job?.id;
const watcherVars = useMemo(() => ({ jobid }), [jobid]);
// Fetch current watchers with refetch capability
const {
data: watcherData,
loading: watcherLoading,
refetch
} = useQuery(GET_JOB_WATCHERS, {
variables: { jobid },
fetchPolicy: "cache-and-network" // Ensure fresh data from server
variables: watcherVars,
skip: !jobid,
fetchPolicy: "cache-first",
notifiyOnNetworkStatusChange: true
});
// Refetch jobWatchers when the popover opens (open changes to true)
// Refetch jobWatchers when the popover opens
useEffect(() => {
if (!jobid) return;
if (open) {
refetch().catch((err) =>
console.error(`Something went wrong fetching Notification Watchers on popover open: ${err?.message}`, {
@@ -49,18 +54,17 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
})
);
}
}, [open, refetch]);
}, [open, refetch, jobid]);
const jobWatchers = useMemo(() => (watcherData?.job_watchers ? [...watcherData.job_watchers] : []), [watcherData]);
const isWatching = jobWatchers.some((w) => w.user_email === userEmail);
// Do NOT clone arrays; keep referential stability for React Compiler and to reduce rerenders.
const jobWatchers = watcherData?.job_watchers ?? EMPTY_ARRAY;
const watcherEmailSet = useMemo(
() => new Set((jobWatchers ?? EMPTY_ARRAY).map((w) => w?.user_email).filter(Boolean)),
[jobWatchers]
);
const isWatching = !!userEmail && watcherEmailSet.has(userEmail);
const [addWatcher, { loading: adding }] = useMutation(ADD_JOB_WATCHER, {
onCompleted: () =>
refetch().catch((err) =>
console.error(`Something went wrong fetching Notification Watchers after add: ${err?.message}`, {
stack: err?.stack
})
),
onError: (err) => {
if (err.graphQLErrors && err.graphQLErrors.length > 0) {
const errorMessage = err.graphQLErrors[0].message;
@@ -69,12 +73,13 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
errorMessage.includes("idx_job_watchers_jobid_user_email_unique")
) {
console.warn("Watcher already exists for this job and user.");
refetch().catch((err) =>
// Only refetch for this edge case to ensure UI is accurate
refetch().catch((e) =>
console.error(
`Something went wrong fetching Notification Watchers after uniqueness violation: ${err?.message}`,
{ stack: err?.stack }
`Something went wrong fetching Notification Watchers after uniqueness violation: ${e?.message}`,
{ stack: e?.stack }
)
); // Sync with server to ensure UI reflects actual state
);
} else {
console.error(`Error adding job watcher: ${errorMessage}`);
}
@@ -83,65 +88,41 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
}
},
update(cache, { data }) {
if (!data || !data.insert_job_watchers_one) {
console.warn("No data or insert_job_watchers_one returned from mutation, skipping cache update.");
refetch().catch((err) =>
console.error(`Something went wrong updating Notification Watchers after add: ${err?.message}`, {
stack: err?.stack
})
);
return;
}
const inserted = data?.insert_job_watchers_one;
if (!inserted) return;
const insert_job_watchers_one = data.insert_job_watchers_one;
const existingData = cache.readQuery({
query: GET_JOB_WATCHERS,
variables: { jobid }
});
cache.updateQuery({ query: GET_JOB_WATCHERS, variables: watcherVars }, (existing) => {
const prev = existing?.job_watchers ?? [];
if (prev.some((w) => w.user_email === inserted.user_email)) return existing;
cache.writeQuery({
query: GET_JOB_WATCHERS,
variables: { jobid },
data: {
...existingData,
job_watchers: [...(existingData?.job_watchers || []), insert_job_watchers_one]
}
return {
...existing,
job_watchers: [...prev, inserted]
};
});
}
});
const [removeWatcher, { loading: removing }] = useMutation(REMOVE_JOB_WATCHER, {
onCompleted: () =>
refetch().catch((err) =>
console.error(`Something went wrong fetching Notification Watchers after remove: ${err?.message}`, {
stack: err?.stack
})
), // Refetch to sync with server after success
onError: (err) => console.error(`Error removing job watcher: ${err.message}`),
update(cache, { data: { delete_job_watchers } }) {
const existingData = cache.readQuery({
query: GET_JOB_WATCHERS,
variables: { jobid }
});
update(cache, { data }) {
const deleted = data?.delete_job_watchers?.returning?.[0];
if (!deleted?.user_email) return;
const deletedWatcher = delete_job_watchers.returning[0];
const updatedWatchers = deletedWatcher
? (existingData?.job_watchers || []).filter((watcher) => watcher.user_email !== deletedWatcher.user_email)
: existingData?.job_watchers || [];
cache.writeQuery({
query: GET_JOB_WATCHERS,
variables: { jobid },
data: {
...existingData,
job_watchers: updatedWatchers
}
cache.updateQuery({ query: GET_JOB_WATCHERS, variables: watcherVars }, (existing) => {
const prev = existing?.job_watchers ?? [];
return {
...existing,
job_watchers: prev.filter((w) => w.user_email !== deleted.user_email)
};
});
}
});
const handleToggleSelf = useCallback(async () => {
if (!jobid || !userEmail) return;
if (adding || removing || !isEmployee) return;
if (isWatching) {
await removeWatcher({ variables: { jobid, userEmail } });
} else {
@@ -151,7 +132,9 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
const handleRemoveWatcher = useCallback(
async (email) => {
if (!jobid) return;
if (removing) return;
if (!email) return;
await removeWatcher({ variables: { jobid, userEmail: email } });
},
[removeWatcher, jobid, removing]
@@ -159,36 +142,45 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
const handleWatcherSelect = useCallback(
async (selectedUser) => {
if (!jobid) return;
if (adding || removing) return;
const employee = bodyshop.employees.find((e) => e.id === selectedUser);
if (!employee) return;
const employee = bodyshop?.employees?.find((e) => e.id === selectedUser);
if (!employee?.user_email) return;
const email = employee.user_email;
const isAlreadyWatching = jobWatchers.some((w) => w.user_email === email);
const isAlreadyWatching = watcherEmailSet.has(email);
if (isAlreadyWatching) {
await handleRemoveWatcher(email);
} else {
await addWatcher({ variables: { jobid, userEmail: email } });
}
setSelectedWatcher(null);
},
[jobWatchers, addWatcher, handleRemoveWatcher, jobid, bodyshop, adding, removing]
[watcherEmailSet, addWatcher, handleRemoveWatcher, jobid, bodyshop, adding, removing]
);
const handleTeamSelect = useCallback(
async (team) => {
if (!jobid) return;
if (adding) return;
const selectedTeamMembers = JSON.parse(team);
const newWatchers = selectedTeamMembers.filter(
(email) => !jobWatchers.some((watcher) => watcher.user_email === email)
);
let selectedTeamMembers = [];
try {
selectedTeamMembers = Array.isArray(team) ? team : JSON.parse(team);
} catch {
selectedTeamMembers = [];
}
const newWatchers = (selectedTeamMembers ?? []).filter(Boolean).filter((email) => !watcherEmailSet.has(email));
if (newWatchers.length === 0) {
console.warn("All selected team members are already watchers.");
setSelectedTeam(null);
return;
}
await Promise.all(
newWatchers.map((email) =>
addWatcher({
@@ -199,8 +191,10 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
})
)
);
setSelectedTeam(null);
},
[jobWatchers, addWatcher, jobid, adding]
[watcherEmailSet, addWatcher, jobid, adding]
);
return (
@@ -223,7 +217,7 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
handleWatcherSelect={handleWatcherSelect}
handleTeamSelect={handleTeamSelect}
currentUser={currentUser}
isEmployee={isEmployee} // Pass isEmployee to the component
isEmployee={isEmployee}
/>
);
}

View File

@@ -53,10 +53,8 @@ export function JobsAdminStatus({ insertAuditTrail, bodyshop, job }) {
return (
<Dropdown menu={statusMenu} trigger={["click"]} key="changestatus">
<Button shape="round">
<Button icon={<DownCircleFilled />} iconPlacement="end" shape="round">
<span>{job.status}</span>
<DownCircleFilled />
</Button>
</Dropdown>
);

View File

@@ -94,11 +94,7 @@ export function JobsAvailableScan({ partnerVersion, refetch }) {
{
title: t("general.labels.actions"),
key: "actions",
render: (text, record) => (
<Button onClick={() => handleImport(record.filepath)}>
<DownloadOutlined />
</Button>
)
render: (text, record) => <Button icon={<DownloadOutlined />} onClick={() => handleImport(record.filepath)} />
}
];
@@ -126,21 +122,21 @@ export function JobsAvailableScan({ partnerVersion, refetch }) {
extra={
<Space wrap>
<Button
icon={<SyncOutlined />}
loading={loading}
disabled={!partnerVersion}
onClick={() => {
scanEstimates();
}}
id="scan-estimates-button"
>
<SyncOutlined />
</Button>
/>
<Input.Search
placeholder={t("general.labels.search")}
onChange={(e) => {
setSearchText(e.currentTarget.value);
}}
enterButton
/>
</Space>
}

View File

@@ -135,17 +135,16 @@ export function JobsAvailableComponent({ bodyshop, loading, data, refetch, addJo
refetch();
});
}}
>
<DeleteFilled />
</Button>
icon={<DeleteFilled />}
/>
{!isClosed && (
<>
<Button onClick={() => addJobAsNew(record)} disabled={record.issupplement}>
<PlusCircleFilled />
</Button>
<Button onClick={() => addJobAsSupp(record)}>
<DownloadOutlined />
</Button>
<Button
onClick={() => addJobAsNew(record)}
disabled={record.issupplement}
icon={<PlusCircleFilled />}
/>
<Button onClick={() => addJobAsSupp(record)} icon={<DownloadOutlined />} />
</>
)}
{isClosed && <Alert type="error" title={t("jobs.labels.alreadyclosed")}></Alert>}
@@ -175,9 +174,8 @@ export function JobsAvailableComponent({ bodyshop, loading, data, refetch, addJo
onClick={() => {
refetch();
}}
>
<SyncOutlined />
</Button>
icon={<SyncOutlined />}
/>
<Button
onClick={() => {
deleteAllAvailableJobs()
@@ -198,13 +196,16 @@ export function JobsAvailableComponent({ bodyshop, loading, data, refetch, addJo
>
{t("general.actions.deleteall")}
</Button>
<Input.Search
placeholder={t("general.labels.search")}
onChange={(e) => {
setSearchText(e.currentTarget.value);
}}
enterButton
/>
<Link to="/manage/jobs/new">
<Button>{t("jobs.actions.manualnew")}</Button>
</Link>
</Space>
}
>

View File

@@ -98,7 +98,7 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
setOwnerModalVisible(false);
setInsertLoading(true);
const estData = replaceEmpty(lazyData?.available_jobs_by_pk || estDataRaw.data.available_jobs_by_pk);
const estData = replaceEmpty(lazyData?.available_jobs_by_pk || estDataRaw?.data?.available_jobs_by_pk);
if (!(estData && estData.est_data)) {
//We don't have the right data. Error!
@@ -226,7 +226,7 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
setJobModalVisible(false);
setInsertLoading(true);
const estData = estDataRaw.data.available_jobs_by_pk;
const estData = estDataRaw?.data?.available_jobs_by_pk;
if (!(estData && estData.est_data)) {
//We don't have the right data. Error!
@@ -369,12 +369,12 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
};
const addJobAsNew = (record) => {
loadEstData({ id: record.id });
loadEstData({ variables: { id: record.id } });
setOwnerModalVisible(true);
};
const addJobAsSupp = useCallback((record) => {
loadEstData({ id: record.id });
loadEstData({ variables: { id: record.id } });
modalSearchState[1](record.clm_no);
setJobModalVisible(true);
}, []);

View File

@@ -86,20 +86,18 @@ export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail, isPar
const statusMenu = {
items: [
...availableStatuses.map((item) => ({
...(availableStatuses?.map((item) => ({
key: item,
label: item
}))
})) ?? [])
],
onClick: (e) => updateJobStatus(e.key)
};
return (
<Dropdown menu={statusMenu} trigger={["click"]} key="changestatus" disabled={jobRO || !job.converted}>
<Button shape="round">
<Button shape="round" icon={<DownCircleFilled />} iconPlacement="end">
<span>{job.status}</span>
<DownCircleFilled />
</Button>
</Dropdown>
);

View File

@@ -42,6 +42,10 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
<tbody>
{fields.map((field, index) => (
<tr key={field.key}>
{/* Hidden field to preserve jobline ID */}
<Form.Item hidden name={[field.name, "id"]}>
<input />
</Form.Item>
<td>
<Form.Item
// label={t("joblines.fields.line_desc")}

View File

@@ -24,7 +24,7 @@ export default function JobsCreateVehicleInfoPredefined({ disabled, form }) {
<div>
<Table
size="small"
title={() => <Input.Search onSearch={(value) => setSearch(value)} />}
title={() => <Input.Search onSearch={(value) => setSearch(value)} enterButton/>}
dataSource={filteredPredefinedVehicles}
columns={[
{
@@ -56,9 +56,8 @@ export default function JobsCreateVehicleInfoPredefined({ disabled, form }) {
setOpen(false);
setSearch("");
}}
>
<PlusOutlined />
</Button>
icon={<PlusOutlined />}
/>
)
}
]}

View File

@@ -1,10 +1,11 @@
import { DownCircleFilled } from "@ant-design/icons";
import { useApolloClient, useMutation } from "@apollo/client/react";
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Card, Dropdown, Form, Input, Modal, Popconfirm, Popover, Select, Space } from "antd";
import { Button, Card, Dropdown, Form, Input, Modal, Popover, Select, Space } from "antd";
import axios from "axios";
import parsePhoneNumber from "libphonenumber-js";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { HasRbacAccess } from "../../components/rbac-wrapper/rbac-wrapper.component";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useNavigate } from "react-router-dom";
@@ -14,13 +15,13 @@ import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { auth, logImEXEvent } from "../../firebase/firebase.utils";
import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries";
import { GET_CURRENT_QUESTIONSET_ID, INSERT_CSI } from "../../graphql/csi.queries";
import { DELETE_JOB, UPDATE_JOB, VOID_JOB } from "../../graphql/jobs.queries";
import { DELETE_JOB, GET_JOB_WATCHERS, UPDATE_JOB, VOID_JOB } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { setEmailOptions } from "../../redux/email/email.actions";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { selectAuthLevel, selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { DateTimeFormatter } from "../../utils/DateFormatter";
import { TemplateList } from "../../utils/TemplateConstants";
@@ -28,16 +29,18 @@ import dayjs from "../../utils/day";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
import LockerWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
import AddToProduction from "./jobs-detail-header-actions.addtoproduction.util";
import DuplicateJob from "./jobs-detail-header-actions.duplicate.util";
import JobsDetailHeaderActionsToggleProduction from "./jobs-detail-header-actions.toggle-production";
const EMPTY_ARRAY = Object.freeze([]);
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
jobRO: selectJobReadOnly,
currentUser: selectCurrentUser
currentUser: selectCurrentUser,
authLevel: selectAuthLevel
});
const mapDispatchToProps = (dispatch) => ({
@@ -115,7 +118,8 @@ export function JobsDetailHeaderActions({
openChatByPhone,
setMessage,
setTimeTicketTaskContext,
setTaskUpsertContext
setTaskUpsertContext,
authLevel
}) {
const { t } = useTranslation();
const client = useApolloClient();
@@ -123,6 +127,10 @@ export function JobsDetailHeaderActions({
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [visibility, setVisibility] = useState(false);
const jobId = job?.id;
const watcherVars = useMemo(() => ({ jobid: jobId }), [jobId]);
const [isCancelScheduleModalVisible, setIsCancelScheduleModalVisible] = useState(false);
const [insertAppointment] = useMutation(INSERT_MANUAL_APPT);
const [deleteJob] = useMutation(DELETE_JOB);
@@ -140,9 +148,20 @@ export function JobsDetailHeaderActions({
const devEmails = ["imex.dev", "rome.dev"];
const prodEmails = ["imex.prod", "rome.prod", "imex.test", "rome.test"];
const canVoidJob = useMemo(() => HasRbacAccess({ authLevel, bodyshop, action: "jobs:void" }), [authLevel, bodyshop]);
const hasValidEmail = (emails) => emails.some((email) => userEmail.endsWith(email));
const canSubmitForTesting = (isDevEnv && hasValidEmail(devEmails)) || (isProdEnv && hasValidEmail(prodEmails));
const { data: jobWatchersData } = useQuery(GET_JOB_WATCHERS, {
variables: watcherVars,
skip: !jobId,
fetchPolicy: "cache-first",
notifyOnNetworkStatusChange: true
});
const jobWatchersCount = jobWatchersData?.job_watchers?.length ?? job?.job_watchers?.length ?? 0;
const {
treatments: { ImEXPay }
} = useTreatmentsWithConfig({
@@ -151,25 +170,78 @@ export function JobsDetailHeaderActions({
splitKey: bodyshop && bodyshop.imexshopid
});
const jobInProduction = useMemo(() => {
return bodyshop.md_ro_statuses.production_statuses.includes(job.status);
}, [job, bodyshop.md_ro_statuses.production_statuses]);
const [visibility, setVisibility] = useState(false);
const productionStatuses = bodyshop?.md_ro_statuses?.production_statuses ?? EMPTY_ARRAY;
const preProductionStatuses = bodyshop?.md_ro_statuses?.pre_production_statuses ?? EMPTY_ARRAY;
const postProductionStatuses = bodyshop?.md_ro_statuses?.post_production_statuses ?? EMPTY_ARRAY;
const jobStatus = job?.status;
const jobInPreProduction = useMemo(() => {
return bodyshop.md_ro_statuses.pre_production_statuses.includes(job.status);
}, [job.status, bodyshop.md_ro_statuses.pre_production_statuses]);
const jobInProduction = productionStatuses.includes(jobStatus);
const jobInPreProduction = preProductionStatuses.includes(jobStatus);
const jobInPostProduction = postProductionStatuses.includes(jobStatus);
const jobInPostProduction = useMemo(() => {
return bodyshop.md_ro_statuses.post_production_statuses.includes(job.status);
}, [job.status, bodyshop.md_ro_statuses.post_production_statuses]);
const makeConfirmId = () =>
globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const [modal, modalContextHolder] = Modal.useModal();
const confirmInstancesRef = useRef(new Map());
const closeConfirmById = (id) => {
const inst = confirmInstancesRef.current.get(id);
if (inst) inst.destroy(); // hard close
confirmInstancesRef.current.delete(id);
};
const openConfirmFromMenu = ({
variant = "confirm", // "confirm" | "info" | "warning"
title,
content,
okText,
cancelText,
showCancel = true,
onOk,
onCancel
}) => {
// close the dropdown immediately; confirm dialog is separate
setDropdownOpen(false);
const id = makeConfirmId();
const openFn = variant === "info" ? modal.info : variant === "warning" ? modal.warning : modal.confirm;
const inst = openFn({
title,
content,
okText,
cancelText,
centered: true,
maskClosable: false,
onCancel: () => {
closeConfirmById(id);
onCancel?.();
},
onOk: async () => {
try {
await onOk?.();
} finally {
closeConfirmById(id);
}
},
...(showCancel ? {} : { okCancel: false })
});
confirmInstancesRef.current.set(id, inst);
return id;
};
const handleDropdownOpenChange = useCallback((nextOpen) => {
setDropdownOpen(nextOpen);
}, []);
// Function to show modal
const showCancelScheduleModal = () => {
setIsCancelScheduleModalVisible(true);
};
// Function to handle Cancel
const handleCancelScheduleModalCancel = () => {
setIsCancelScheduleModalVisible(false);
};
@@ -178,7 +250,7 @@ export function JobsDetailHeaderActions({
DuplicateJob({
apolloClient: client,
jobId: job.id,
config: { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported },
config: { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported, timezone: bodyshop.timezone },
completionCallback: (newJobId) => {
history(`/manage/jobs/${newJobId}`);
notification.success({
@@ -193,7 +265,7 @@ export function JobsDetailHeaderActions({
DuplicateJob({
apolloClient: client,
jobId: job.id,
config: { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported },
config: { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported, timezone: bodyshop.timezone },
completionCallback: (newJobId) => {
history(`/manage/jobs/${newJobId}`);
notification.success({
@@ -390,6 +462,11 @@ export function JobsDetailHeaderActions({
};
const handleVoidJob = async () => {
if (!canVoidJob) {
notification.error({ title: t("general.messages.rbacunauth") });
return;
}
//delete the job.
const result = await voidJob({
variables: {
@@ -549,12 +626,6 @@ export function JobsDetailHeaderActions({
});
};
// Function to handle OK
const handleCancelScheduleOK = async () => {
await form.submit(); // Assuming 'form' is the Form instance from useForm()
setIsCancelScheduleModalVisible(false);
};
const handleLostSaleFinish = async ({ lost_sale_reason }) => {
const jobUpdate = await cancelAllAppointments({
variables: {
@@ -884,34 +955,26 @@ export function JobsDetailHeaderActions({
{
key: "duplicate",
id: "job-actions-duplicate",
label: (
<Popconfirm
title={t("jobs.labels.duplicateconfirm")}
okText="Yes"
cancelText="No"
onClick={(e) => e.stopPropagation()}
onConfirm={handleDuplicate}
getPopupContainer={(trigger) => trigger.parentNode}
>
{t("menus.jobsactions.duplicate")}
</Popconfirm>
)
label: t("menus.jobsactions.duplicate"),
onClick: () =>
openConfirmFromMenu({
title: t("jobs.labels.duplicateconfirm"),
okText: t("general.labels.yes"),
cancelText: t("general.labels.no"),
onOk: handleDuplicate
})
},
{
key: "duplicatenolines",
id: "job-actions-duplicatenolines",
label: (
<Popconfirm
title={t("jobs.labels.duplicateconfirm")}
okText="Yes"
cancelText="No"
onClick={(e) => e.stopPropagation()}
onConfirm={handleDuplicateConfirm}
getPopupContainer={(trigger) => trigger.parentNode}
>
{t("menus.jobsactions.duplicatenolines")}
</Popconfirm>
)
label: t("menus.jobsactions.duplicatenolines"),
onClick: () =>
openConfirmFromMenu({
title: t("jobs.labels.duplicateconfirm"),
okText: t("general.labels.yes"),
cancelText: t("general.labels.no"),
onOk: handleDuplicateConfirm
})
}
]
},
@@ -1084,22 +1147,25 @@ export function JobsDetailHeaderActions({
menuItems.push({
key: "deletejob",
id: "job-actions-deletejob",
label:
job.job_watchers.length === 0 ? (
<Popconfirm
title={t("jobs.labels.deleteconfirm")}
okText={t("general.labels.yes")}
cancelText={t("general.labels.no")}
onClick={(e) => e.stopPropagation()}
onConfirm={handleDeleteJob}
>
{t("menus.jobsactions.deletejob")}
</Popconfirm>
) : (
<Popconfirm title={t("jobs.labels.deletewatchers")} onClick={(e) => e.stopPropagation()} showCancel={false}>
{t("menus.jobsactions.deletejob")}
</Popconfirm>
)
label: t("menus.jobsactions.deletejob"),
onClick: () => {
if (jobWatchersCount === 0) {
openConfirmFromMenu({
title: t("jobs.labels.deleteconfirm"),
okText: t("general.labels.yes"),
cancelText: t("general.labels.no"),
onOk: handleDeleteJob
});
} else {
// informational "OK only"
openConfirmFromMenu({
variant: "info",
title: t("jobs.labels.deletewatchers"),
okText: t("general.actions.ok"),
showCancel: false
});
}
}
});
}
@@ -1112,23 +1178,18 @@ export function JobsDetailHeaderActions({
label: t("appointments.labels.manualevent")
});
if (!jobRO && job.converted) {
if (!jobRO && job.converted && canVoidJob) {
menuItems.push({
key: "voidjob",
id: "job-actions-voidjob",
label: (
<RbacWrapper action="jobs:void" noauth>
<Popconfirm
title={t("jobs.labels.voidjob")}
okText={t("general.labels.yes")}
cancelText={t("general.labels.no")}
onClick={(e) => e.stopPropagation()}
onConfirm={handleVoidJob}
>
{t("menus.jobsactions.void")}
</Popconfirm>
</RbacWrapper>
)
label: t("menus.jobsactions.void"),
onClick: () =>
openConfirmFromMenu({
title: t("jobs.labels.voidjob"),
okText: t("general.labels.yes"),
cancelText: t("general.labels.no"),
onOk: handleVoidJob
})
});
}
@@ -1160,23 +1221,16 @@ export function JobsDetailHeaderActions({
return (
<>
{modalContextHolder}
<Modal
title={t("menus.jobsactions.cancelallappointments")}
open={isCancelScheduleModalVisible}
onOk={handleCancelScheduleOK}
onCancel={handleCancelScheduleModalCancel}
footer={[
<Button form="cancelScheduleForm" key="back" onClick={handleCancelScheduleModalCancel}>
{t("general.actions.cancel")}
</Button>,
<Button
form="cancelScheduleForm"
htmlType="submit"
key="submit"
type="primary"
loading={loading}
onClick={handleCancelScheduleOK}
>
<Button form="cancelScheduleForm" htmlType="submit" key="submit" type="primary" loading={loading}>
{t("appointments.actions.cancel")}
</Button>
]}
@@ -1184,9 +1238,12 @@ export function JobsDetailHeaderActions({
<Form
layout="vertical"
id="cancelScheduleForm"
onFinish={(s) => {
console.log(s);
handleLostSaleFinish(s);
onFinish={async (s) => {
try {
await handleLostSaleFinish(s);
} finally {
setIsCancelScheduleModalVisible(false);
}
}}
>
<Form.Item
@@ -1208,12 +1265,19 @@ export function JobsDetailHeaderActions({
</Form.Item>
</Form>
</Modal>
<Dropdown menu={menu} trigger={["click"]} key="changestatus" open={dropdownOpen} onOpenChange={setDropdownOpen}>
<Button>
<Dropdown
menu={menu}
trigger={["click"]}
key="changestatus"
open={dropdownOpen}
onOpenChange={handleDropdownOpenChange}
>
<Button icon={<DownCircleFilled />} iconPlacement="end">
<span>{t("general.labels.actions")}</span>
<DownCircleFilled />
</Button>
</Dropdown>
<Popover content={popOverContent} open={visibility} />
</>
);

View File

@@ -15,7 +15,7 @@ export default async function DuplicateJob({
}) {
logImEXEvent("job_duplicate");
const { defaultOpenStatus } = config;
const { defaultOpenStatus, timezone } = config;
//get a list of all fields on the job
const res = await apolloClient.query({
query: QUERY_JOB_FOR_DUPE,
@@ -31,9 +31,12 @@ export default async function DuplicateJob({
delete existingJob.updatedat;
delete existingJob.cieca_stl;
delete existingJob.cieca_ttl;
!keepJobLines && delete existingJob.clm_total;
const newJob = {
...existingJob,
date_estimated: dayjs().tz(timezone, false).format("YYYY-MM-DD"),
date_open: dayjs(),
status: defaultOpenStatus
};
@@ -70,7 +73,7 @@ export default async function DuplicateJob({
export async function CreateIouForJob({ apolloClient, jobId, config, jobLinesToKeep, currentUser }) {
logImEXEvent("job_create_iou");
const { status } = config;
const { status, timezone } = config;
//get a list of all fields on the job
const res = await apolloClient.query({
query: QUERY_JOB_FOR_DUPE,
@@ -88,10 +91,10 @@ export async function CreateIouForJob({ apolloClient, jobId, config, jobLinesToK
const newJob = {
...existingJob,
converted: true,
status: status,
iouparent: jobId,
date_estimated: dayjs().tz(timezone, false).format("YYYY-MM-DD"),
date_open: dayjs(),
audit_trails: {
data: [

View File

@@ -203,7 +203,7 @@ export function JobsDetailHeaderActionsToggleProduction({
open={popOverVisible}
onOpenChange={setPopOverVisible}
onClick={(e) => {
getJobDetails({ id: job.id });
getJobDetails({ variables: { id: job.id } });
e.stopPropagation();
}}
getPopupContainer={(trigger) => trigger.parentNode}

View File

@@ -65,8 +65,8 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
const colSpan = {
xs: { span: 24 },
sm: { span: 24 },
md: { span: isPartsEntry ? 8 : 12 },
lg: { span: isPartsEntry ? 8 : 6 },
md: { span: 12 },
lg: { span: 12 },
xl: { span: isPartsEntry ? 8 : 6 }
};
@@ -143,7 +143,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
label={t("jobs.fields.comment")}
styles={{ value: { overflow: "hidden", textOverflow: "ellipsis" } }}
>
<ProductionListColumnComment record={job} />
<ProductionListColumnComment record={job} usePortal={true} />
</DataLabel>
{!isPartsEntry && <DataLabel label={t("jobs.fields.ins_co_nm_short")}>{job.ins_co_nm}</DataLabel>}
<DataLabel label={t("jobs.fields.clm_no")}>{job.clm_no}</DataLabel>
@@ -176,7 +176,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
</DataLabel>
)}
<DataLabel label={t("jobs.fields.production_vars.note")}>
<ProductionListColumnProductionNote record={job} />
<ProductionListColumnProductionNote record={job} usePortal={true} />
</DataLabel>
<DataLabel label={t("jobs.fields.estimate_sent_approval")}>
<Space>
@@ -260,19 +260,19 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
<ChatOpenButton type={job.ownr_ph1_ty} phone={job.ownr_ph1} jobid={job.id} />
)}
</DataLabel>
<DataLabel key="22" label={t("jobs.fields.ownr_ph2")}>
<DataLabel key="3" label={t("jobs.fields.ownr_ph2")}>
{disabled || isPartsEntry ? (
<PhoneNumberFormatter type={job.ownr_ph2_ty}>{job.ownr_ph2}</PhoneNumberFormatter>
) : (
<ChatOpenButton type={job.ownr_ph2_ty} phone={job.ownr_ph2} jobid={job.id} />
)}
</DataLabel>
<DataLabel key="3" label={t("owners.fields.address")}>
<DataLabel key="4" label={t("owners.fields.address")}>
{`${job.ownr_addr1 || ""} ${job.ownr_addr2 || ""} ${
job.ownr_city || ""
} ${job.ownr_st || ""} ${job.ownr_zip || ""}`}
</DataLabel>
<DataLabel key="4" label={t("owners.fields.ownr_ea")}>
<DataLabel key="5" label={t("owners.fields.ownr_ea")}>
{disabled || isPartsEntry ? (
<>{job.ownr_ea || ""}</>
) : job.ownr_ea ? (
@@ -280,13 +280,14 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
) : null}
</DataLabel>
{job.owner?.tax_number && (
<DataLabel key="5" label={t("owners.fields.tax_number")}>
<DataLabel key="6" label={t("owners.fields.tax_number")}>
{job.owner?.tax_number || ""}
</DataLabel>
)}
<DataLabel
label={t("owners.fields.note")}
styles={{ value: { overflow: "hidden", textOverflow: "ellipsis" } }}
key="7"
>
{job.owner?.note || ""}
</DataLabel>

View File

@@ -4,9 +4,11 @@ import AlertComponent from "../alert/alert.component";
import JobsDetailLaborComponent from "./jobs-detail-labor.component";
export default function JobsDetailLaborContainer({ jobId, techConsole, job }) {
const id = jobId ?? null;
const { loading, error, data, refetch } = useQuery(GET_LINE_TICKET_BY_PK, {
variables: { id: jobId },
skip: !jobId,
variables: { id },
skip: !id,
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
@@ -16,7 +18,7 @@ export default function JobsDetailLaborContainer({ jobId, techConsole, job }) {
return (
<JobsDetailLaborComponent
loading={loading}
jobId={jobId}
jobId={id}
timetickets={data ? data.timetickets : []}
joblines={data ? data.joblines : []}
refetch={refetch}

View File

@@ -5,7 +5,7 @@ import JobTotalsTable from "../job-totals-table/job-totals-table.component";
export function JobsDetailTotals({ job, refetch }) {
return (
<div>
<JobTotalsTable job={job} />
<JobTotalsTable job={job} refetch={refetch} />
<Divider />
<JobPayments job={job} refetch={refetch} />
</div>

View File

@@ -128,9 +128,7 @@ function JobsDocumentsComponent({
<Row gutter={[16, 16]}>
<Col span={24}>
<Space wrap>
<Button onClick={() => refetch && refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch && refetch()} icon={<SyncOutlined />} />
<JobsDocumentsGallerySelectAllComponent galleryImages={galleryImages} setGalleryImages={setgalleryImages} />
<JobsDocumentsDownloadButton galleryImages={galleryImages} identifier={downloadIdentifier} />
<JobsDocumentsDeleteButton galleryImages={galleryImages} deletionCallback={billsCallback || refetch} />

View File

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

View File

@@ -65,9 +65,8 @@ function JobsDocumentsImgproxyComponent({
//Do the imgproxy refresh too
fetchThumbnails();
}}
>
<SyncOutlined />
</Button>
icon={<SyncOutlined />}
/>
<JobsDocumentsGallerySelectAllComponent galleryImages={galleryImages} setGalleryImages={setGalleryImages} />
{!billId && (
<JobsDocumentsGalleryReassign galleryImages={galleryImages} callback={fetchThumbnails || refetch} />

View File

@@ -102,9 +102,8 @@ export function JobsDocumentsLocalGallery({
}
}
}}
>
<SyncOutlined />
</Button>
icon={<SyncOutlined />}
/>
<a href={CreateExplorerLinkForJob({ jobid: job.id })}>
<Button>{t("documents.labels.openinexplorer")}</Button>
</a>

View File

@@ -179,9 +179,8 @@ export default function JobsFindModalComponent({
onClick={() => {
jobsListRefetch();
}}
>
<SyncOutlined />
</Button>
icon={<SyncOutlined />}
/>
<Input
value={modalSearch}
onChange={(e) => {

View File

@@ -224,9 +224,7 @@ export function JobsList({ bodyshop, refetch, loading, jobs, total }) {
</Button>
</>
)}
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Input.Search
placeholder={search.search || t("general.labels.search")}
onSearch={(value) => {

View File

@@ -313,9 +313,7 @@ export function JobsList({ bodyshop }) {
title={t("titles.bc.jobs-active")}
extra={
<Space wrap>
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Input.Search
placeholder={t("general.labels.search")}
onChange={(e) => {

View File

@@ -9,7 +9,6 @@ import { DateTimeFormatter } from "../../utils/DateFormatter";
import { TemplateList } from "../../utils/TemplateConstants";
import useLocalStorage from "../../utils/useLocalStorage";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import NoteUpsertModal from "../note-upsert-modal/note-upsert-modal.container";
import PrintWrapperComponent from "../print-wrapper/print-wrapper.component";
import JobNotesPinToggle from "../job-notes-pin-toggle/job-notes-pin-toggle.component";
@@ -122,9 +121,12 @@ export function JobNotesComponent({
width: 200,
render: (text, record) => (
<Space wrap>
<Button loading={deleteLoading} disabled={record.audit || jobRO} onClick={() => handleNoteDelete(record.id)}>
<DeleteFilled />
</Button>
<Button
loading={deleteLoading}
disabled={record.audit || jobRO}
onClick={() => handleNoteDelete(record.id)}
icon={<DeleteFilled />}
/>
<Button
disabled={record.audit || jobRO}
onClick={() => {
@@ -136,9 +138,8 @@ export function JobNotesComponent({
}
});
}}
>
<EditFilled />
</Button>
icon={<EditFilled />}
/>
<PrintWrapperComponent
templateObject={{
name: Templates.individual_job_note.key,
@@ -184,8 +185,6 @@ export function JobNotesComponent({
</Button>
}
>
<NoteUpsertModal />
<Table loading={loading} columns={columns} rowKey="id" dataSource={data} onChange={handleTableChange} />
</Card>
</div>

View File

@@ -297,9 +297,7 @@ export function JobsReadyList({ bodyshop }) {
extra={
<Space wrap>
<span>({readyStatuses && readyStatuses.join(", ")})</span>
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Input.Search
placeholder={t("general.labels.search")}
onChange={(e) => {

View File

@@ -246,9 +246,8 @@ export function PayrollLaborAllocationsTable({
setTotals(data);
refetch();
}}
>
<SyncOutlined />
</Button>
icon={<SyncOutlined />}
/>
</Space>
}
>

View File

@@ -24,6 +24,7 @@ const NotificationCenterComponent = ({
onNotificationClick,
unreadCount,
isEmployee,
isDarkMode,
ref
}) => {
const { t } = useTranslation();
@@ -112,14 +113,16 @@ const NotificationCenterComponent = ({
<Alert title={t("notifications.labels.employee-notification")} type="warning" />
</div>
) : (
<Virtuoso
ref={virtuosoRef}
style={{ height: "400px", width: "100%" }}
data={notifications}
totalCount={notifications.length}
endReached={loadMore}
itemContent={renderNotification}
/>
<div className={isDarkMode ? "notification-center--dark" : "notification-center--light"} style={{ height: "400px", width: "100%" }}>
<Virtuoso
ref={virtuosoRef}
style={{ height: "100%", width: "100%" }}
data={notifications}
totalCount={notifications.length}
endReached={loadMore}
itemContent={renderNotification}
/>
</div>
)}
</div>
);

View File

@@ -5,6 +5,7 @@ import NotificationCenterComponent from "./notification-center.component";
import { GET_NOTIFICATIONS } from "../../graphql/notifications.queries";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
import { selectDarkMode } from "../../redux/application/application.selectors.js";
import day from "../../utils/day.js";
import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
@@ -22,7 +23,7 @@ const NOTIFICATION_POLL_INTERVAL_SECONDS = 60;
* @returns {JSX.Element}
* @constructor
*/
const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount, currentUser }) => {
const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount, currentUser, isDarkMode }) => {
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
const [notifications, setNotifications] = useState([]);
const [isLoading, setIsLoading] = useState(false);
@@ -213,13 +214,15 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount,
loadMore={loadMore}
onNotificationClick={handleNotificationClick}
unreadCount={unreadCount}
isDarkMode={isDarkMode}
/>
);
};
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
currentUser: selectCurrentUser,
isDarkMode: selectDarkMode
});
export default connect(mapStateToProps, null)(NotificationCenterContainer);

View File

@@ -173,3 +173,11 @@
}
}
}
.notification-center--dark {
color-scheme: dark;
}
.notification-center--light {
color-scheme: light;
}

View File

@@ -29,7 +29,7 @@ export default function OwnerFindModalContainer({
const s = OwnerNameDisplayFunction(owner, true);
setSearchText(s.trim());
callSearchowners({ search: s.trim() });
callSearchowners({ variables: { search: s.trim() } });
}
}, [callSearchowners, modalProps.open, owner]);
@@ -47,7 +47,8 @@ export default function OwnerFindModalContainer({
<Input.Search
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onSearch={(val) => callSearchowners({ search: val.trim() })}
onSearch={(val) => callSearchowners({ variables: { search: val.trim() } })}
enterButton
/>
<OwnerFindModalComponent
selectedOwner={selectedOwner}

View File

@@ -17,7 +17,7 @@ const OwnerSearchSelect = ({ value, onChange, onBlur, disabled, ref }) => {
);
const executeSearch = (v) => {
if (v && v.variables?.search !== "" && v.variables.search.length >= 2) callSearch(v);
if (v && v.variables?.search !== "" && v.variables.search.length >= 2) callSearch({ variables: v.variables });
};
const debouncedExecuteSearch = _.debounce(executeSearch, 500);
@@ -29,7 +29,7 @@ const OwnerSearchSelect = ({ value, onChange, onBlur, disabled, ref }) => {
useEffect(() => {
if (value === option && value) {
callIdSearch({ id: value });
callIdSearch({ variables: { id: value } });
}
}, [value, option, callIdSearch]);

View File

@@ -99,9 +99,7 @@ export default function OwnersListComponent({ loading, owners, total, refetch })
</Button>
</>
)}
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Input.Search
placeholder={search.search || t("general.labels.search")}
onSearch={(value) => {

View File

@@ -93,10 +93,7 @@ export function PartDispatchTableComponent({ bodyshop, job, billsQuery }) {
title={t("parts_dispatch.labels.parts_dispatch")}
extra={
<Space wrap>
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Input.Search
placeholder={t("general.labels.search")}
value={searchText}
@@ -104,6 +101,7 @@ export function PartDispatchTableComponent({ bodyshop, job, billsQuery }) {
e.preventDefault();
setSearchText(e.target.value);
}}
enterButton
/>
</Space>
}

View File

@@ -34,9 +34,7 @@ export default function PartsOrderDeleteLine({ disabled, partsLineId, partsOrder
});
}}
>
<Button disabled={disabled}>
<DeleteFilled />
</Button>
<Button disabled={disabled} icon={<DeleteFilled />} />
</Popconfirm>
);
}

View File

@@ -101,7 +101,9 @@ export function PartsOrderListTableDrawerComponent({
if (selectedPartsOrderRecord?.returnfrombill) {
try {
const { data } = await billQuery({
billid: selectedPartsOrderRecord.returnfrombill
variables: {
billid: selectedPartsOrderRecord.returnfrombill
}
});
setBillData(data);
} catch (error) {
@@ -148,9 +150,8 @@ export function PartsOrderListTableDrawerComponent({
}
});
}}
>
<FaTasks />
</Button>
icon={<FaTasks />}
/>
)}
<Popconfirm
title={t("parts_orders.labels.confirmdelete")}
@@ -171,9 +172,7 @@ export function PartsOrderListTableDrawerComponent({
});
}}
>
<Button disabled={jobRO}>
<DeleteFilled />
</Button>
<Button disabled={jobRO} icon={<DeleteFilled />} />
</Popconfirm>
{!isPartsEntry && (
<Button

View File

@@ -295,6 +295,7 @@ export function PartsOrderListTableComponent({
e.preventDefault();
setSearchText(e.target.value);
}}
enterButton
/>
</Space>
}

View File

@@ -34,13 +34,13 @@ export default function PartsOrderModalPriceChange({ form, field }) {
{
key: "custom",
label: (
<InputNumber
onClick={(e) => e.stopPropagation()}
addonAfter="%"
onKeyUp={(e) => {
if (e.key === "Enter") {
const values = form.getFieldsValue();
const { parts_order_lines } = values;
<Space.Compact>
<InputNumber
onClick={(e) => e.stopPropagation()}
onKeyUp={(e) => {
if (e.key === "Enter") {
const values = form.getFieldsValue();
const { parts_order_lines } = values;
form.setFieldsValue({
parts_order_lines: {
@@ -56,10 +56,12 @@ export default function PartsOrderModalPriceChange({ form, field }) {
});
e.target.value = 0;
}
}}
min={0}
max={100}
/>
}}
min={0}
max={100}
/>
<span style={{ padding: "0 11px", backgroundColor: "#fafafa", border: "1px solid #d9d9d9", borderLeft: 0 }}>%</span>
</Space.Compact>
)
}
],

View File

@@ -13,6 +13,13 @@ import PartsOrderModalPriceChange from "./parts-order-modal-price-change.compone
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import { selectIsPartsEntry } from "../../redux/application/application.selectors.js";
const PriceInputWrapper = ({ value, onChange, form, field }) => (
<Space.Compact style={{ width: "100%" }}>
<PartsOrderModalPriceChange form={form} field={field} />
<CurrencyInput style={{ flex: 1 }} value={value} onChange={onChange} />
</Space.Compact>
);
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
isPartsEntry: selectIsPartsEntry
@@ -58,8 +65,8 @@ export function PartsOrderModalComponent({
return (
<div>
<Form.Item name="returnfrombill" style={{ display: "none" }}>
<Input />
<Form.Item name="returnfrombill" hidden>
<Input type="hidden" />
</Form.Item>
<LayoutFormRow grow noDivider>
<Form.Item
@@ -199,7 +206,7 @@ export function PartsOrderModalComponent({
key={`${index}act_price`}
name={[field.name, "act_price"]}
>
<CurrencyInput addonBefore={<PartsOrderModalPriceChange form={form} field={field} />} />
<PriceInputWrapper form={form} field={field} />
</Form.Item>
{isReturn && (
<Form.Item

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