Compare commits

..

111 Commits

Author SHA1 Message Date
Allan Carr
790ab0447f IO-3322 IntelliPay Refund
Add logging to capture Response from IntelliPay API on success

Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-08-06 16:30:56 -07:00
Dave Richer
3737fe457f Merged in release/2025-08-01 (pull request #2448)
Release/2025 08 01 into master-AIO - IO-2604, IO-3292, IO-3310, IO-3315, IO-3316, IO-3318, IO-3319, IO-3320
2025-08-02 01:21:40 +00:00
Allan Carr
bb4e8eb5bd Merged in feature/IO-3320-Responsibility-Center-RBAC (pull request #2446)
IO-3320 Responsibility Center RBAC

Approved-by: Dave Richer
2025-07-31 19:51:38 +00:00
Allan Carr
27a07e8d5d IO-3320 Responsibility Center RBAC
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-07-31 12:49:56 -07:00
Allan Carr
e2618eee83 Merged in feature/IO-2604-vendor-tagging (pull request #2444)
IO-2604 Vendor Tagging English Translation

Approved-by: Dave Richer
2025-07-31 19:47:53 +00:00
Dave Richer
66c51a4be5 release/2025-08-01 - Fix Notes via refetch queries, cache manipulation too complex 2025-07-31 15:46:34 -04:00
Allan Carr
d5afcaeaab IO-2604 Vendor Tagging English Translation
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-07-31 11:35:02 -07:00
Allan Carr
c332ec11b7 Merged in feature/IO-3319-Job-Status-Change (pull request #2442)
IO-3319 Job Status Change

Approved-by: Dave Richer
2025-07-30 18:13:59 +00:00
Allan Carr
cf31290f05 IO-3319 Job Status Change
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-07-30 11:12:15 -07:00
Allan Carr
dbbab910b6 Merged in feature/IO-3318-Close-Job-Page-UI-Bugs (pull request #2440)
IO-3318 Close Job Page UI Bugs

Approved-by: Dave Richer
2025-07-30 17:10:40 +00:00
Allan Carr
abf01b4966 IO-3318 Close Job Page UI Bugs
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-07-29 19:19:38 -07:00
Allan Carr
a965f9edf5 Merged in feature/IO-3315-CDK-InService-Date (pull request #2437)
IO-3315 CDK InService Date

Approved-by: Dave Richer
2025-07-29 17:24:51 +00:00
Allan Carr
f02ca05eba Merged in feature/IO-3316-Local-Storage-Sort (pull request #2436)
IO-3316 Local Storage Sort

Approved-by: Dave Richer
2025-07-29 17:23:26 +00:00
Allan Carr
a182ea0869 IO-3315 CDK InService Date
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-07-29 10:04:13 -07:00
Allan Carr
7bc2d41a68 IO-3316 Local Storage Sort
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-07-28 14:05:59 -07:00
Allan Carr
5277e90946 Merged in feature/IO-3310-Shop-Data-Preservation (pull request #2433)
IO-3310 Shop Data Preservation on Hidden Fields

Approved-by: Dave Richer
2025-07-24 17:50:14 +00:00
Allan Carr
15ea4e6afa IO-3310 Shop Data Preservation on Hidden Fields
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-07-22 17:40:51 -07:00
Dave Richer
5b3b6a409c Merged in hotfix/2025-07-22-SocketProvider (pull request #2431)
Hotfix/2025 07 22 SocketProvider
2025-07-23 00:16:13 +00:00
Patrick Fic
d92bab113e Merged in hotfix/2025-07-22-SocketProvider (pull request #2430)
Hotfix/2025 07 22 SocketProvider
2025-07-22 21:24:59 +00:00
Patrick Fic
93c6e2b601 Revert socket transport settings. 2025-07-22 14:23:25 -07:00
Dave Richer
19a90571f6 Down Socket Reconnects 2025-07-22 17:11:52 -04:00
Patrick Fic
736e9cedfa Merged in feature/IO-2604-vendor-tagging (pull request #2428)
IO-2604 Add vendor tags to search select & vendor edit screen.

Approved-by: Dave Richer
2025-07-22 18:09:57 +00:00
Patrick Fic
c433103e1b IO-2604 Merge in conflicted translations file. 2025-07-22 10:25:04 -07:00
Patrick Fic
2892fdbb58 IO-2604 reformat translations to resolve conflict. 2025-07-22 10:17:10 -07:00
Patrick Fic
c45f38e47b IO-2604 Add vendor tags to search select & vendor edit screen. 2025-07-22 10:03:07 -07:00
Patrick Fic
54a58c9fbc Merged in feature/IO-3292-pinned-notes (pull request #2427)
IO-3292 Add note pinning functionality.

Approved-by: Dave Richer
2025-07-22 16:13:51 +00:00
Patrick Fic
1934ae0758 IO-3292 Add note pinning functionality. 2025-07-22 09:03:41 -07:00
Dave Richer
953e70efef Merged in release/2025-07-18 (pull request #2422)
Release 2025-07-18 into master-AIO - IO-1054, IO-3252, IO-3286, IO-3291, IO-3296, IO-3303, IO-3309
2025-07-19 00:57:33 +00:00
Dave Richer
a6bae390e5 feature/IO-3255-simplified-parts-management - Merge release / resolve conflicts 2025-07-18 14:55:00 -04:00
Allan Carr
cf9d8d649d Merged in feature/IO-3309-Date-Restriction-Removal (pull request #2419)
IO-3309 Date Field Rescriton Removal

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

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

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

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

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

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

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

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

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

Approved-by: Dave Richer
2025-06-27 18:22:56 +00:00
Dave Richer
2293119518 Merged in feature/IO-3288-Score-Board-Blocked-Days (pull request #2390)
feature/IO-3288-Score-Board-Blocked-Days - Fix
2025-06-27 17:57:25 +00:00
Dave Richer
bd529a0dfa feature/IO-3288-Score-Board-Blocked-Days - Fix 2025-06-27 13:56:18 -04:00
Allan Carr
57ad89747f IO-3286 Additional Product Fruit IDs
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-06-26 18:48:09 -07:00
Allan Carr
3ae8f38adb IO-3285 Shop Config Lite Basic
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-06-26 18:45:48 -07:00
Allan Carr
dc5ed1a39c IO-3284 JobLine Price Lbr Hrs Total
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-06-26 18:40:38 -07:00
Patrick Fic
aa6e6b8980 Merged in hotfix/2025-06-25 (pull request #2386)
IO-3281 Resolve key issue for downloads.
2025-06-25 23:33:37 +00:00
Patrick Fic
1dc80c068b Merged in feature/IO-3281-imgproxy-download-hotfix (pull request #2385)
IO-3281 Resolve key issue for downloads.
2025-06-25 23:33:14 +00:00
Patrick Fic
bd0c4ceae2 IO-3281 Resolve key issue for downloads. 2025-06-25 16:32:47 -07:00
Patrick Fic
30b58c6ea5 Merged in hotfix/2025-06-25 (pull request #2384)
IO-3281 missed file in previous commit.
2025-06-25 22:48:53 +00:00
Patrick Fic
a55e9224f8 Merged in feature/IO-3281-imgproxy-download-hotfix (pull request #2383)
IO-3281 missed file in previous commit.
2025-06-25 22:48:41 +00:00
Patrick Fic
0c80abb3ca IO-3281 missed file in previous commit. 2025-06-25 15:48:06 -07:00
Patrick Fic
7137e611cd Merged in hotfix/2025-06-25 (pull request #2382)
IO-3281 Prevent broken stream reseting HTTP headers.
2025-06-25 22:37:01 +00:00
Patrick Fic
6f9d291d36 Merged in feature/IO-3281-imgproxy-download-hotfix (pull request #2381)
IO-3281 Prevent broken stream reseting HTTP headers.
2025-06-25 22:36:47 +00:00
Patrick Fic
f2a2653eae IO-3281 Prevent broken stream reseting HTTP headers. 2025-06-25 15:36:03 -07:00
Patrick Fic
73c25ab91f Merged in hotfix/2025-06-25 (pull request #2380)
Hotfix/2025 06 25 - IO-3279 IO-3281
2025-06-25 17:08:57 +00:00
Patrick Fic
780449bac6 Merge branch 'hotfix/2025-06-25' of bitbucket.org:snaptsoft/bodyshop into hotfix/2025-06-25 2025-06-25 10:08:09 -07:00
Patrick Fic
2509a1ecf3 Merge branch 'feature/IO-3279-usage-report' into hotfix/2025-06-25 2025-06-25 10:08:02 -07:00
Patrick Fic
16075f7ddd Merged in feature/IO-3281-imgproxy-download-hotfix (pull request #2378)
IO-3281 Adjust zip to stream.
2025-06-25 16:43:29 +00:00
Patrick Fic
27d28e7ffc IO-3281 Adjust zip to stream. 2025-06-25 09:42:45 -07:00
Patrick Fic
66b87e5c45 Merged in feature/IO-3281-imgproxy-download-hotfix (pull request #2376)
IO-3281 resolve imgproxy download failures.
2025-06-25 15:53:33 +00:00
Patrick Fic
c1e1dff7d2 IO-3281 resolve imgproxy download failures. 2025-06-25 08:51:41 -07:00
Dave Richer
f76eb7abf5 Merged in hotfix/IO-3280-Image-Selection-Bug (pull request #2374)
hotfix/IO-3280-Image-Selection-Bug - Fix Bug in image selection dialog
2025-06-24 18:43:29 +00:00
Dave Richer
25ea2a80a3 hotfix/IO-3280-Image-Selection-Bug - Fix Bug in image selection dialog 2025-06-24 14:42:06 -04:00
101 changed files with 10870 additions and 7504 deletions

2
.gitignore vendored
View File

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

View File

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

File diff suppressed because it is too large Load Diff

195
client/package-lock.json generated
View File

@@ -20,8 +20,8 @@
"@firebase/messaging": "^0.12.21",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.8.2",
"@sentry/cli": "^2.46.0",
"@sentry/react": "^9.27.0",
"@sentry/cli": "^2.47.1",
"@sentry/react": "^9.38.0",
"@sentry/vite-plugin": "^3.5.0",
"@splitsoftware/splitio-react": "^2.3.1",
"@tanem/react-nprogress": "^5.0.53",
@@ -42,7 +42,7 @@
"i18next": "^24.2.3",
"i18next-browser-languagedetector": "^8.1.0",
"immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.9",
"libphonenumber-js": "^1.12.10",
"logrocket": "^9.0.2",
"markerjs2": "^2.32.4",
"memoize-one": "^6.0.0",
@@ -91,11 +91,11 @@
"@ant-design/icons": "^6.0.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.27.1",
"@dotenvx/dotenvx": "^1.44.1",
"@dotenvx/dotenvx": "^1.47.5",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.28.0",
"@playwright/test": "^1.51.1",
"@eslint/js": "^9.31.0",
"@playwright/test": "^1.54.1",
"@sentry/webpack-plugin": "^3.5.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
@@ -111,7 +111,7 @@
"jsdom": "^26.0.0",
"memfs": "^4.17.2",
"os-browserify": "^0.3.0",
"playwright": "^1.51.1",
"playwright": "^1.54.1",
"react-error-overlay": "^6.1.0",
"redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.3",
@@ -2588,9 +2588,9 @@
}
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.44.1.tgz",
"integrity": "sha512-j1QImCqf/XJmhIjC1OPpgiZV9g370HG9MNT9s/CDwCKsoYzNCPEKK+GfsidahJx7yIlBbm+4dPLlGec+bKn7oA==",
"version": "1.47.5",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.47.5.tgz",
"integrity": "sha512-FtDgJyqOXmkj+BTU0qcE4Iq2HKjrEH6ZhRMc7m8fmOwstf1Ms9/9lbLNzyiNqyQrEnVr38W8PTTWMbDB3IX5og==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -2605,8 +2605,7 @@
"which": "^4.0.0"
},
"bin": {
"dotenvx": "src/cli/dotenvx.js",
"git-dotenvx": "src/cli/dotenvx.js"
"dotenvx": "src/cli/dotenvx.js"
},
"funding": {
"url": "https://dotenvx.com"
@@ -2913,9 +2912,9 @@
}
},
"node_modules/@eslint/js": {
"version": "9.28.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.28.0.tgz",
"integrity": "sha512-fnqSjGWd/CoIp4EXIxWVK/sHA6DOHN4+8Ix2cX5ycOY7LG0UY8nHCU5pIp2eaE1Mc7Qd8kHspYNzYXT2ojPLzg==",
"version": "9.31.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz",
"integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3519,13 +3518,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz",
"integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==",
"version": "1.54.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz",
"integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.52.0"
"playwright": "1.54.1"
},
"bin": {
"playwright": "cli.js"
@@ -4469,50 +4468,50 @@
"license": "MIT"
},
"node_modules/@sentry-internal/browser-utils": {
"version": "9.27.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.27.0.tgz",
"integrity": "sha512-SJa7f6Ct1BzP8rWEomnshSGN1CmT+axNKvT+StrbFPD6AyHnYfFLJpKgc2iToIJHB/pmeuOI9dUwqtzVx+5nSw==",
"version": "9.38.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.38.0.tgz",
"integrity": "sha512-BkTaMPm4pjgoT1qNsLX5e3HjTCwBmsR/OGyKHFpMUnN+HINi9L1nGGbRroOEtfU49vMKi8MlM7HpuzzYV/3D1A==",
"license": "MIT",
"dependencies": {
"@sentry/core": "9.27.0"
"@sentry/core": "9.38.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "9.27.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.27.0.tgz",
"integrity": "sha512-e7L8eG0y63RulN352lmafoCCfQGg4jLVT8YLx6096eWu/YKLkgmVpgi8livsT5WREnH+HB+iFSrejOwK7cRkhw==",
"version": "9.38.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.38.0.tgz",
"integrity": "sha512-vDVufE9WLqHCmUL2sa3nIKz5ARaBdaqCG+b9/hwkmkLnqaQUBiHE+ArxoYuc2toWqaELxSHcMDp2ajkeDBQeLA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "9.27.0"
"@sentry/core": "9.38.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "9.27.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.27.0.tgz",
"integrity": "sha512-n2kO1wOfCG7GxkMAqbYYkpgTqJM5tuVLdp0JuNCqTOLTXWvw6svWGaYKlYpKUgsK9X/GDzJYSXZmfe+Dbg+FJQ==",
"version": "9.38.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.38.0.tgz",
"integrity": "sha512-LLZuQk5Khvco+EYKg2+woiSNMLyR4XZeoAdgvAa+HZriFoAQR6GFNAuu+TlynCDDt2H+w90HcIAV66NWFy8QoQ==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "9.27.0",
"@sentry/core": "9.27.0"
"@sentry-internal/browser-utils": "9.38.0",
"@sentry/core": "9.38.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "9.27.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.27.0.tgz",
"integrity": "sha512-44rVSt3LCH6qePYRQrl4WUBwnkOk9dzinmnKmuwRksEdDOkVq5KBRhi/IDr7omwSpX8C+KrX5alfKhOx1cP0gQ==",
"version": "9.38.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.38.0.tgz",
"integrity": "sha512-87BZDTjszdaSB5p0CTiVav2QgxLMAab/6q1jcIUBzNsrXHZbqcoMaJmd446mCsQkR6wAccM/uAxJlgh9FIqA8w==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "9.27.0",
"@sentry/core": "9.27.0"
"@sentry-internal/replay": "9.38.0",
"@sentry/core": "9.38.0"
},
"engines": {
"node": ">=18"
@@ -4528,16 +4527,16 @@
}
},
"node_modules/@sentry/browser": {
"version": "9.27.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.27.0.tgz",
"integrity": "sha512-geR3lhRJOmUQqi1WgovLSYcD/f66zYnctdnDEa7j1BW2XIB1nlTJn0mpYyAHghXKkUN/pBpp1Z+Jk0XlVwFYVg==",
"version": "9.38.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.38.0.tgz",
"integrity": "sha512-ZUIeU+3VUD3BntYgB2DkhBD6N9oybsuk1+U7yK1ezHIw/nvkPILcH6MZgPs0Km0RcWWozMUDSbdZNud9/isYmw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "9.27.0",
"@sentry-internal/feedback": "9.27.0",
"@sentry-internal/replay": "9.27.0",
"@sentry-internal/replay-canvas": "9.27.0",
"@sentry/core": "9.27.0"
"@sentry-internal/browser-utils": "9.38.0",
"@sentry-internal/feedback": "9.38.0",
"@sentry-internal/replay": "9.38.0",
"@sentry-internal/replay-canvas": "9.38.0",
"@sentry/core": "9.38.0"
},
"engines": {
"node": ">=18"
@@ -4728,9 +4727,9 @@
}
},
"node_modules/@sentry/cli": {
"version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.46.0.tgz",
"integrity": "sha512-nqoPl7UCr446QFkylrsRrUXF51x8Z9dGquyf4jaQU+OzbOJMqclnYEvU6iwbwvaw3tu/2DnoZE/Og+Nq1h63sA==",
"version": "2.47.1",
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.47.1.tgz",
"integrity": "sha512-t45lfyyMYs6L1oFUmtYuLDJFf0o6a0IGbPJvzOZcP3lmidouEG5nloBF6FG39AkL29pwrS2WN41j2gyDjrQ71g==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -4747,20 +4746,20 @@
"node": ">= 10"
},
"optionalDependencies": {
"@sentry/cli-darwin": "2.46.0",
"@sentry/cli-linux-arm": "2.46.0",
"@sentry/cli-linux-arm64": "2.46.0",
"@sentry/cli-linux-i686": "2.46.0",
"@sentry/cli-linux-x64": "2.46.0",
"@sentry/cli-win32-arm64": "2.46.0",
"@sentry/cli-win32-i686": "2.46.0",
"@sentry/cli-win32-x64": "2.46.0"
"@sentry/cli-darwin": "2.47.1",
"@sentry/cli-linux-arm": "2.47.1",
"@sentry/cli-linux-arm64": "2.47.1",
"@sentry/cli-linux-i686": "2.47.1",
"@sentry/cli-linux-x64": "2.47.1",
"@sentry/cli-win32-arm64": "2.47.1",
"@sentry/cli-win32-i686": "2.47.1",
"@sentry/cli-win32-x64": "2.47.1"
}
},
"node_modules/@sentry/cli-darwin": {
"version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.46.0.tgz",
"integrity": "sha512-5Ll+e5KAdIk9OYiZO8aifMBRNWmNyPjSqdjaHlBC1Qfh7pE3b1zyzoHlsUazG0bv0sNrSGea8e7kF5wIO1hvyg==",
"version": "2.47.1",
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.47.1.tgz",
"integrity": "sha512-Vq+8Hs1AR5MFYCI8vkz+rdRJmcNgUf8b8dW8aSLYCHy7wS/X61OB00LupLaaaoN5c/xemb0rZCg4M0ftUqB5Kw==",
"license": "BSD-3-Clause",
"optional": true,
"os": [
@@ -4771,9 +4770,9 @@
}
},
"node_modules/@sentry/cli-linux-arm": {
"version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.46.0.tgz",
"integrity": "sha512-WRrLNq/TEX/TNJkGqq6Ad0tGyapd5dwlxtsPbVBrIdryuL1mA7VCBoaHBr3kcwJLsgBHFH0lmkMee2ubNZZdkg==",
"version": "2.47.1",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.47.1.tgz",
"integrity": "sha512-Wkcvr0LYP1XMSoaczQnUtOSZPfyBzdGk7wQyloYWyMv9oZWJYkt1wYI0/FaNM+MIX15RqEAx0nI5CjotLMlj8w==",
"cpu": [
"arm"
],
@@ -4789,9 +4788,9 @@
}
},
"node_modules/@sentry/cli-linux-arm64": {
"version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.46.0.tgz",
"integrity": "sha512-OEJN8yAjI9y5B4telyqzu27Hi3+S4T8VxZCqJz1+z2Mp0Q/MZ622AahVPpcrVq/5bxrnlZR16+lKh8L1QwNFPg==",
"version": "2.47.1",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.47.1.tgz",
"integrity": "sha512-Kuda8/BFMVyqYayQjP0NQnxnAz5Xpfo2crG1/RRXF9lYQ9O/5YRb3dvlMPX6WasplCzajaSuLrYt/LXcs4McwA==",
"cpu": [
"arm64"
],
@@ -4807,9 +4806,9 @@
}
},
"node_modules/@sentry/cli-linux-i686": {
"version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.46.0.tgz",
"integrity": "sha512-xko3/BVa4LX8EmRxVOCipV+PwfcK5Xs8lP6lgF+7NeuAHMNL4DqF6iV9rrN8gkGUHCUI9RXSve37uuZnFy55+Q==",
"version": "2.47.1",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.47.1.tgz",
"integrity": "sha512-WB3FbRjeJmKHhGc5CftaFFJfFc7c+Mu/XKwbI8Es/9f65bVWdB6BA2tH7aHyoAQngA++1ZVXUJwUpxYPNxQEag==",
"cpu": [
"x86",
"ia32"
@@ -4826,9 +4825,9 @@
}
},
"node_modules/@sentry/cli-linux-x64": {
"version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.46.0.tgz",
"integrity": "sha512-hJ1g5UEboYcOuRia96LxjJ0jhnmk8EWLDvlGnXLnYHkwy3ree/L7sNgdp/QsY8Z4j2PGO5f22Va+UDhSjhzlfQ==",
"version": "2.47.1",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.47.1.tgz",
"integrity": "sha512-C+3GJLDpZQMO45toUKiF4bPZpxQiU5/10LtZg2vhpUyyzFGNseVQO/Bsnu9hG/LVjYGLkTgEaorl1liRQsfKVg==",
"cpu": [
"x64"
],
@@ -4844,9 +4843,9 @@
}
},
"node_modules/@sentry/cli-win32-arm64": {
"version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.46.0.tgz",
"integrity": "sha512-mN7cpPoCv2VExFRGHt+IoK11yx4pM4ADZQGEso5BAUZ5duViXB2WrAXCLd8DrwMnP0OE978a7N8OtzsFqjkbNA==",
"version": "2.47.1",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.47.1.tgz",
"integrity": "sha512-K3yb1yLvA6Lh0UaXjsU6lP/2uOMkZ47cVq0dFxL/hEr4fBHRkXuvg3oOJNDkJ2xXt2W2s7AIa83T2EisZ0a/NQ==",
"cpu": [
"arm64"
],
@@ -4860,9 +4859,9 @@
}
},
"node_modules/@sentry/cli-win32-i686": {
"version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.46.0.tgz",
"integrity": "sha512-6F73AUE3lm71BISUO19OmlnkFD5WVe4/wA1YivtLZTc1RU3eUYJLYxhDfaH3P77+ycDppQ2yCgemLRaA4A8mNQ==",
"version": "2.47.1",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.47.1.tgz",
"integrity": "sha512-wk+6IIT+VT28c9uPe9PDzxdh+OiTEDb/0PIdFv1khSfAmEuVSNWzuDWsra7MnA7OPfgzzNDPkP4HRW1CKb3Xiw==",
"cpu": [
"x86",
"ia32"
@@ -4877,9 +4876,9 @@
}
},
"node_modules/@sentry/cli-win32-x64": {
"version": "2.46.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.46.0.tgz",
"integrity": "sha512-yuGVcfepnNL84LGA0GjHzdMIcOzMe0bjPhq/rwPsPN+zu11N+nPR2wV2Bum4U0eQdqYH3iAlMdL5/BEQfuLJww==",
"version": "2.47.1",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.47.1.tgz",
"integrity": "sha512-blseDhuUJDsb+3Ku9dvR4b0JO4nunRokF/9jzW+qHqTha7UHE2kQYXkCfsoDg65juvJFeKeQASYV7VphEJgIGQ==",
"cpu": [
"x64"
],
@@ -4914,22 +4913,22 @@
}
},
"node_modules/@sentry/core": {
"version": "9.27.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.27.0.tgz",
"integrity": "sha512-Zb2SSAdWXQjTem+sVWrrAq9L6YYfxyoTwtapaE6C6qZBR5C8Uak0wcYww8StaCFH7dDA/PSW+VxOwjNXocrQHQ==",
"version": "9.38.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.38.0.tgz",
"integrity": "sha512-dUwSv1VXDfsrcY69a/cgZNDsFal6iYOf0C4T+/ylpmgYp5SVe3vQK+2FLXUMuvgnOf+kHO6IeW0RhnhSyUflmA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/react": {
"version": "9.27.0",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-9.27.0.tgz",
"integrity": "sha512-UT7iaGEwTqe06O4mgHfKGTRBHg+U0JSI/id+QxrOji6ksosOsSnSC3Vdq+gPs9pzCCFE+6+DkH6foYNNLIN0lw==",
"version": "9.38.0",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-9.38.0.tgz",
"integrity": "sha512-MGnrzEJdwCEhGnQrFvljCGM+19agsC5ONAExRM+TuCVjeDJ/ifegZ4eEUyaGHt7YyjAUszddSbWbpEBUg2zBvQ==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "9.27.0",
"@sentry/core": "9.27.0",
"@sentry/browser": "9.38.0",
"@sentry/core": "9.38.0",
"hoist-non-react-statics": "^3.3.2"
},
"engines": {
@@ -11499,9 +11498,9 @@
}
},
"node_modules/libphonenumber-js": {
"version": "1.12.9",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.9.tgz",
"integrity": "sha512-VWwAdNeJgN7jFOD+wN4qx83DTPMVPPAUyx9/TUkBXKLiNkuWWk6anV0439tgdtwaJDrEdqkvdN22iA6J4bUCZg==",
"version": "1.12.10",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.10.tgz",
"integrity": "sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ==",
"license": "MIT"
},
"node_modules/lines-and-columns": {
@@ -13340,13 +13339,13 @@
}
},
"node_modules/playwright": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
"integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==",
"version": "1.54.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz",
"integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.52.0"
"playwright-core": "1.54.1"
},
"bin": {
"playwright": "cli.js"
@@ -13359,9 +13358,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz",
"integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==",
"version": "1.54.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz",
"integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==",
"dev": true,
"license": "Apache-2.0",
"bin": {

View File

@@ -19,8 +19,8 @@
"@firebase/messaging": "^0.12.21",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.8.2",
"@sentry/cli": "^2.46.0",
"@sentry/react": "^9.27.0",
"@sentry/cli": "^2.47.1",
"@sentry/react": "^9.38.0",
"@sentry/vite-plugin": "^3.5.0",
"@splitsoftware/splitio-react": "^2.3.1",
"@tanem/react-nprogress": "^5.0.53",
@@ -41,7 +41,7 @@
"i18next": "^24.2.3",
"i18next-browser-languagedetector": "^8.1.0",
"immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.9",
"libphonenumber-js": "^1.12.10",
"logrocket": "^9.0.2",
"markerjs2": "^2.32.4",
"memoize-one": "^6.0.0",
@@ -131,11 +131,11 @@
"@ant-design/icons": "^6.0.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.27.1",
"@dotenvx/dotenvx": "^1.44.1",
"@dotenvx/dotenvx": "^1.47.5",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.28.0",
"@playwright/test": "^1.51.1",
"@eslint/js": "^9.31.0",
"@playwright/test": "^1.54.1",
"@sentry/webpack-plugin": "^3.5.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
@@ -151,7 +151,7 @@
"jsdom": "^26.0.0",
"memfs": "^4.17.2",
"os-browserify": "^0.3.0",
"playwright": "^1.51.1",
"playwright": "^1.54.1",
"react-error-overlay": "^6.1.0",
"redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.3",

View File

@@ -1,6 +1,6 @@
import { Card, Checkbox, Input, Space, Table } from "antd";
import queryString from "query-string";
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
@@ -16,12 +16,13 @@ import PayableExportAll from "../payable-export-all-button/payable-export-all-bu
import PayableExportButton from "../payable-export-button/payable-export-button.component";
import BillMarkSelectedExported from "../payable-mark-selected-exported/payable-mark-selected-exported.component";
import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component";
import useLocalStorage from "./../../utils/useLocalStorage";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
@@ -31,7 +32,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, bills, ref
const { t } = useTranslation();
const [selectedBills, setSelectedBills] = useState([]);
const [transInProgress, setTransInProgress] = useState(false);
const [state, setState] = useState({
const [state, setState] = useLocalStorage("accounting-payables-table-state", {
sortedInfo: {},
search: ""
});
@@ -181,7 +182,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, bills, ref
onChange={handleTableChange}
rowSelection={{
onSelectAll: (selected, selectedRows) => setSelectedBills(selectedRows.map((i) => i.id)),
onSelect: (record, selected, selectedRows, nativeEvent) => {
onSelect: (record, selected, selectedRows) => {
setSelectedBills(selectedRows.map((i) => i.id));
},
getCheckboxProps: (record) => ({

View File

@@ -1,5 +1,5 @@
import { Card, Input, Space, Table } from "antd";
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
@@ -10,6 +10,7 @@ import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateFormatter, DateTimeFormatter } from "../../utils/DateFormatter";
import { exportPageLimit } from "../../utils/config";
import { alphaSort, dateSort } from "../../utils/sorters";
import useLocalStorage from "../../utils/useLocalStorage";
import ExportLogsCountDisplay from "../export-logs-count-display/export-logs-count-display.component";
import OwnerNameDisplay, { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import PaymentExportButton from "../payment-export-button/payment-export-button.component";
@@ -21,7 +22,7 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
@@ -31,7 +32,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, payments,
const { t } = useTranslation();
const [selectedPayments, setSelectedPayments] = useState([]);
const [transInProgress, setTransInProgress] = useState(false);
const [state, setState] = useState({
const [state, setState] = useLocalStorage("accounting-payments-table-state", {
sortedInfo: {},
search: ""
});
@@ -194,7 +195,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, payments,
onChange={handleTableChange}
rowSelection={{
onSelectAll: (selected, selectedRows) => setSelectedPayments(selectedRows.map((i) => i.id)),
onSelect: (record, selected, selectedRows, nativeEvent) => {
onSelect: (record, selected, selectedRows) => {
setSelectedPayments(selectedRows.map((i) => i.id));
},
getCheckboxProps: (record) => ({

View File

@@ -1,5 +1,5 @@
import { Button, Card, Input, Space, Table } from "antd";
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
@@ -10,6 +10,7 @@ import { exportPageLimit } from "../../utils/config";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateFormatter } from "../../utils/DateFormatter";
import { alphaSort, dateSort, statusSort } from "../../utils/sorters";
import useLocalStorage from "../../utils/useLocalStorage";
import ExportLogsCountDisplay from "../export-logs-count-display/export-logs-count-display.component";
import JobExportButton from "../jobs-close-export-button/jobs-close-export-button.component";
import JobsExportAllButton from "../jobs-export-all-button/jobs-export-all-button.component";
@@ -20,7 +21,7 @@ import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(AccountingReceivablesTableComponent);
@@ -30,7 +31,7 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
const [selectedJobs, setSelectedJobs] = useState([]);
const [transInProgress, setTransInProgress] = useState(false);
const [state, setState] = useState({
const [state, setState] = useLocalStorage("accounting-receivables-table-state", {
sortedInfo: {},
search: ""
});
@@ -207,7 +208,7 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
onChange={handleTableChange}
rowSelection={{
onSelectAll: (selected, selectedRows) => setSelectedJobs(selectedRows.map((i) => i.id)),
onSelect: (record, selected, selectedRows, nativeEvent) => {
onSelect: (record, selected, selectedRows) => {
setSelectedJobs(selectedRows.map((i) => i.id));
},
getCheckboxProps: (record) => ({

View File

@@ -25,6 +25,7 @@ import BillFormContainer from "../bill-form/bill-form.container";
import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility";
import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility";
import { handleUpload } from "../documents-upload/documents-upload.utility";
import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility";
const mapStateToProps = createStructuredSelector({
billEnterModal: selectBillEnterModal,
@@ -53,10 +54,10 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
const notification = useNotification();
const {
treatments: { Enhanced_Payroll }
treatments: { Enhanced_Payroll, Imgproxy }
} = useSplitTreatments({
attributes: {},
names: ["Enhanced_Payroll"],
names: ["Enhanced_Payroll", "Imgproxy"],
splitKey: bodyshop.imexshopid
});
@@ -298,20 +299,39 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
});
});
} else {
upload.forEach((u) => {
handleUpload(
{ file: u.originFileObj },
{
bodyshop: bodyshop,
uploaded_by: currentUser.email,
jobId: values.jobid,
billId: billId,
tagsArray: null,
callback: null
},
notification
);
});
//Check if using Imgproxy or cloudinary
if (Imgproxy.treatment === "on") {
upload.forEach((u) => {
handleUploadToImageProxy(
{ file: u.originFileObj },
{
bodyshop: bodyshop,
uploaded_by: currentUser.email,
jobId: values.jobid,
billId: billId,
tagsArray: null,
callback: null
},
notification
);
});
} else {
upload.forEach((u) => {
handleUpload(
{ file: u.originFileObj },
{
bodyshop: bodyshop,
uploaded_by: currentUser.email,
jobId: values.jobid,
billId: billId,
tagsArray: null,
callback: null
},
notification
);
});
}
}
}
///////////////////////////

View File

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

View File

@@ -110,7 +110,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
trigger="click"
open={open}
onOpenChange={handleVisibleChange}
overlayClassName="media-selector-popover"
classNames={{ root: "media-selector-popover" }}
>
<Badge count={selectedMedia.filter((s) => s.isSelected).length}>
<PictureFilled style={{ margin: "0 .5rem" }} />

View File

@@ -1,5 +1,6 @@
.media-selector-popover {
.ant-popover-inner-content {
position: relative;
max-width: 640px;
max-height: 480px;
overflow-y: auto;
@@ -36,11 +37,6 @@
border-radius: 4px;
margin: 4px;
cursor: pointer;
transition: transform 0.2s;
&:hover {
transform: scale(1.05);
}
}
/* Grid layout for gallery components */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import React, { forwardRef } from "react";
import { forwardRef } from "react";
import { useTranslation } from "react-i18next";
const LaborTypeFormItem = ({ value, onChange }, ref) => {
const LaborTypeFormItem = ({ value }) => {
const { t } = useTranslation();
if (!value) return null;

View File

@@ -1,11 +1,13 @@
import React, { forwardRef } from "react";
import { forwardRef } from "react";
import { useTranslation } from "react-i18next";
const PartTypeFormItem = ({ value, onChange }, ref) => {
const PartTypeFormItem = ({ value }) => {
const { t } = useTranslation();
if (!value) return null;
return <div>{t(`joblines.fields.part_types.${value}`)}</div>;
return (
<div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{t(`joblines.fields.part_types.${value}`)}</div>
);
};
export default forwardRef(PartTypeFormItem);

View File

@@ -1,6 +1,5 @@
import Dinero from "dinero.js";
import React, { forwardRef } from "react";
import { forwardRef } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
@@ -8,23 +7,24 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
const ReadOnlyFormItem = ({ bodyshop, value, type = "text", onChange }, ref) => {
const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
if (!value) return null;
switch (type) {
case "employee":
case "employee": {
const emp = bodyshop.employees.find((e) => e.id === value);
return `${emp?.first_name} ${emp?.last_name}`;
}
case "text":
return <div>{value}</div>;
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
case "currency":
return <div>{Dinero({ amount: Math.round(value * 100) }).toFormat()}</div>;
default:
return <div>{value}</div>;
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
}
};

View File

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

View File

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

View File

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

View File

@@ -385,7 +385,9 @@ export function ScheduleEventComponent({
previousEvent: event.id,
color: event.color,
alt_transport: event.job && event.job.alt_transport,
note: event.note
note: event.note,
scheduled_in: event.job && event.job.scheduled_in,
scheduled_completion: event.job && event.job.scheduled_completion
}
});
}}

View File

@@ -0,0 +1,25 @@
import { PushpinFilled, PushpinOutlined } from "@ant-design/icons";
import { useMutation } from "@apollo/client";
import { UPDATE_NOTE } from "../../graphql/notes.queries";
function JobNotesPinToggle({ note }) {
const [updateNote] = useMutation(UPDATE_NOTE);
const handlePinToggle = () => {
updateNote({
variables: {
noteId: note.id,
note: { pinned: !note.pinned }
},
refetchQueries: ["GET_JOB_BY_PK", "QUERY_JOB_CARD_DETAILS", "QUERY_PARTS_QUEUE_CARD_DETAILS"]
});
};
return note.pinned ? (
<PushpinFilled size="large" onClick={handlePinToggle} style={{ color: "gold" }} />
) : (
<PushpinOutlined size="large" onClick={handlePinToggle} />
);
}
export default JobNotesPinToggle;

View File

@@ -1,16 +1,16 @@
import { DownCircleFilled } from "@ant-design/icons";
import { useMutation } from "@apollo/client";
import { Button, Dropdown } from "antd";
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { UPDATE_JOB_STATUS } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -24,7 +24,6 @@ export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail }) {
const { t } = useTranslation();
const [availableStatuses, setAvailableStatuses] = useState([]);
const [otherStages, setOtherStages] = useState([]);
const [mutationUpdateJobstatus] = useMutation(UPDATE_JOB_STATUS);
const notification = useNotification();
@@ -32,7 +31,7 @@ export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail }) {
mutationUpdateJobstatus({
variables: { jobId: job.id, status: status }
})
.then((r) => {
.then(() => {
notification["success"]({ message: t("jobs.successes.save") });
insertAuditTrail({
jobid: job.id,
@@ -41,7 +40,7 @@ export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail }) {
});
// refetch();
})
.catch((error) => {
.catch(() => {
notification["error"]({ message: t("jobs.errors.saving") });
});
};
@@ -51,19 +50,14 @@ export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail }) {
if (job && bodyshop) {
if (bodyshop.md_ro_statuses.pre_production_statuses.includes(job.status)) {
setAvailableStatuses(bodyshop.md_ro_statuses.pre_production_statuses);
if (bodyshop.md_ro_statuses.production_statuses[0])
setOtherStages([bodyshop.md_ro_statuses.production_statuses[0]]);
} else if (bodyshop.md_ro_statuses.production_statuses.includes(job.status)) {
setAvailableStatuses(bodyshop.md_ro_statuses.production_statuses);
setOtherStages([bodyshop.md_ro_statuses.default_imported, bodyshop.md_ro_statuses.default_delivered]);
} else if (bodyshop.md_ro_statuses.post_production_statuses.includes(job.status)) {
setAvailableStatuses(
bodyshop.md_ro_statuses.post_production_statuses.filter(
(s) => s !== bodyshop.md_ro_statuses.default_invoiced && s !== bodyshop.md_ro_statuses.default_exported
)
);
if (bodyshop.md_ro_statuses.production_statuses[0])
setOtherStages([bodyshop.md_ro_statuses.production_statuses[0]]);
} else {
console.log("Status didn't match any restrictions. Allowing all status changes.");
setAvailableStatuses(bodyshop.md_ro_statuses.statuses);
@@ -76,16 +70,7 @@ export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail }) {
...availableStatuses.map((item) => ({
key: item,
label: item
})),
...(job.converted
? [
{ type: "divider" },
...otherStages.map((item) => ({
key: item,
label: item
}))
]
: [])
}))
],
onClick: (e) => updateJobStatus(e.key)
};

View File

@@ -1,5 +1,5 @@
import { WarningOutlined } from "@ant-design/icons";
import { Form, Select, Space, Tooltip } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -8,14 +8,13 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import LaborTypeFormItem from "../form-items-formatted/labor-type-form-item.component";
import PartTypeFormItem from "../form-items-formatted/part-type-form-item.component";
import ReadOnlyFormItem from "../form-items-formatted/read-only-form-item.component";
import { WarningOutlined } from "@ant-design/icons";
import "./jobs-close-lines.styles.scss";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
jobRO: selectJobReadOnly
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
@@ -24,7 +23,7 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
return (
<div>
<Form.List name={["joblines"]}>
{(fields, { add, remove, move }) => {
{(fields) => {
return (
<table className="jobs-close-table">
<thead>

View File

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

View File

@@ -673,7 +673,9 @@ export function JobsDetailHeaderActions({
context: {
jobId: job.id,
job: job,
alt_transport: job.alt_transport
alt_transport: job.alt_transport,
scheduled_in: job.scheduled_in,
scheduled_completion: job.scheduled_completion
}
});
}
@@ -1090,11 +1092,7 @@ export function JobsDetailHeaderActions({
{t("menus.jobsactions.deletejob")}
</Popconfirm>
) : (
<Popconfirm
title={t("jobs.labels.deletewatchers")}
onClick={(e) => e.stopPropagation()}
showCancel={false}
>
<Popconfirm title={t("jobs.labels.deletewatchers")} onClick={(e) => e.stopPropagation()} showCancel={false}>
{t("menus.jobsactions.deletejob")}
</Popconfirm>
)

View File

@@ -23,6 +23,7 @@ import JobAltTransportChange from "../job-at-change/job-at-change.component";
import JobEmployeeAssignments from "../job-employee-assignments/job-employee-assignments.container";
import JobsRelatedRos from "../jobs-related-ros/jobs-related-ros.component";
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import PinnedJobNotes from "../pinned-job-notes/pinned-job-notes.component.jsx";
import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component";
import ProductionListColumnProductionNote from "../production-list-columns/production-list-columns.productionnote.component";
import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component";
@@ -102,250 +103,257 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail })
};
return (
<Row gutter={[16, 16]} style={{ alignItems: "stretch" }}>
<Col {...colSpan}>
<Card title={"Job Status"} style={{ height: "100%" }}>
<div>
<DataLabel label={t("jobs.fields.status")}>
<>
<Row gutter={[16, 16]} style={{ alignItems: "stretch" }}>
<Col {...colSpan}>
<Card title={"Job Status"} style={{ height: "100%" }}>
<div>
<DataLabel label={t("jobs.fields.status")}>
<Space wrap>
{job.status}
{job.inproduction && <Tag color="#f50">{t("jobs.labels.inproduction")}</Tag>}
{job.suspended && <PauseCircleOutlined style={{ color: "orangered" }} />}
{job.iouparent && (
<Link to={`/manage/jobs/${job.iouparent}`}>
<Tooltip title={t("jobs.labels.iou")}>
<BranchesOutlined style={{ color: "orangered" }} />
</Tooltip>
</Link>
)}
{job.production_vars && job.production_vars.alert ? (
<ExclamationCircleFilled className="production-alert" />
) : null}
{job.status === bodyshop.md_ro_statuses.default_scheduled && job.scheduled_in ? (
<Tag>
<Link to={`/manage/schedule?date=${dayjs(job.scheduled_in).format("YYYY-MM-DD")}`}>
<DateTimeFormatter>{job.scheduled_in}</DateTimeFormatter>
</Link>
</Tag>
) : null}
</Space>
</DataLabel>
<DataLabel label={t("jobs.fields.comment")} valueStyle={{ overflow: "hidden", textOverflow: "ellipsis" }}>
<ProductionListColumnComment record={job} />
</DataLabel>
<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>
<DataLabel label={t("jobs.fields.ponumber")} hideIfNull>
{job.po_number}
</DataLabel>
<DataLabel label={t("jobs.fields.repairtotal")}>
<CurrencyFormatter>{job.clm_total}</CurrencyFormatter>
<span style={{ margin: "0rem .5rem" }}>/</span>
<CurrencyFormatter>{job.owner_owing}</CurrencyFormatter>
</DataLabel>
<DataLabel label={t("jobs.fields.alt_transport")}>
{job.alt_transport}
<JobAltTransportChange job={job} />
</DataLabel>
{job?.cccontracts?.length > 0 && (
<DataLabel label={t("jobs.labels.contracts")}>
{job.cccontracts.map((c, index) => (
<Space key={c.id} wrap>
<Link to={`/manage/courtesycars/contracts/${c.id}`}>
{`${c.agreementnumber} - ${c.courtesycar.fleetnumber} ${c.courtesycar.year} ${c.courtesycar.make} ${c.courtesycar.model}`}
{index !== job.cccontracts.length - 1 ? "," : null}
</Link>
</Space>
))}
</DataLabel>
)}
<DataLabel label={t("jobs.fields.production_vars.note")}>
<ProductionListColumnProductionNote record={job} />
</DataLabel>
<DataLabel label={t("jobs.fields.estimate_sent_approval")}>
<Space>
<Checkbox
checked={!!job.estimate_sent_approval}
onChange={(e) => handleCheckboxChange("estimate_sent_approval", e.target.checked)}
disabled={disabled}
>
{job.estimate_sent_approval && (
<span style={{ color: "#888" }}>
<DateTimeFormatter>{job.estimate_sent_approval}</DateTimeFormatter>
</span>
)}
</Checkbox>
</Space>
</DataLabel>
<DataLabel label={t("jobs.fields.estimate_approved")}>
<Space>
<Checkbox
checked={!!job.estimate_approved}
onChange={(e) => handleCheckboxChange("estimate_approved", e.target.checked)}
disabled={disabled}
>
{job.estimate_approved && (
<span style={{ color: "#888" }}>
<DateTimeFormatter>{job.estimate_approved}</DateTimeFormatter>
</span>
)}
</Checkbox>
</Space>
</DataLabel>
<Space wrap>
{job.status}
{job.inproduction && <Tag color="#f50">{t("jobs.labels.inproduction")}</Tag>}
{job.suspended && <PauseCircleOutlined style={{ color: "orangered" }} />}
{job.iouparent && (
<Link to={`/manage/jobs/${job.iouparent}`}>
<Tooltip title={t("jobs.labels.iou")}>
<BranchesOutlined style={{ color: "orangered" }} />
</Tooltip>
</Link>
)}
{job.production_vars && job.production_vars.alert ? (
<ExclamationCircleFilled className="production-alert" />
) : null}
{job.status === bodyshop.md_ro_statuses.default_scheduled && job.scheduled_in ? (
<Tag>
<Link to={`/manage/schedule?date=${dayjs(job.scheduled_in).format("YYYY-MM-DD")}`}>
<DateTimeFormatter>{job.scheduled_in}</DateTimeFormatter>
</Link>
{job.special_coverage_policy && (
<Tag color="tomato">
<Space>
<WarningFilled />
<span>{t("jobs.labels.specialcoveragepolicy")}</span>
</Space>
</Tag>
) : null}
)}
{job.ca_gst_registrant && (
<Tag color="geekblue">
<Space>
<WarningFilled />
<span>{t("jobs.fields.ca_gst_registrant")}</span>
</Space>
</Tag>
)}
{job.hit_and_run && (
<Tag color="green">
<Space>
<WarningFilled />
<span>{t("jobs.fields.hit_and_run")}</span>
</Space>
</Tag>
)}
</Space>
</DataLabel>
<DataLabel label={t("jobs.fields.comment")} valueStyle={{ overflow: "hidden", textOverflow: "ellipsis" }}>
<ProductionListColumnComment record={job} />
</DataLabel>
<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>
<DataLabel label={t("jobs.fields.ponumber")} hideIfNull>
{job.po_number}
</DataLabel>
<DataLabel label={t("jobs.fields.repairtotal")}>
<CurrencyFormatter>{job.clm_total}</CurrencyFormatter>
<span style={{ margin: "0rem .5rem" }}>/</span>
<CurrencyFormatter>{job.owner_owing}</CurrencyFormatter>
</DataLabel>
<DataLabel label={t("jobs.fields.alt_transport")}>
{job.alt_transport}
<JobAltTransportChange job={job} />
</DataLabel>
{job?.cccontracts?.length > 0 && (
<DataLabel label={t("jobs.labels.contracts")}>
{job.cccontracts.map((c, index) => (
<Space key={c.id} wrap>
<Link to={`/manage/courtesycars/contracts/${c.id}`}>
{`${c.agreementnumber} - ${c.courtesycar.fleetnumber} ${c.courtesycar.year} ${c.courtesycar.make} ${c.courtesycar.model}`}
{index !== job.cccontracts.length - 1 ? "," : null}
</Link>
</Space>
))}
</DataLabel>
)}
<DataLabel label={t("jobs.fields.production_vars.note")}>
<ProductionListColumnProductionNote record={job} />
</DataLabel>
<DataLabel label={t("jobs.fields.estimate_sent_approval")}>
<Space>
<Checkbox
checked={!!job.estimate_sent_approval}
onChange={(e) => handleCheckboxChange("estimate_sent_approval", e.target.checked)}
disabled={disabled}
>
{job.estimate_sent_approval && (
<span style={{ color: "#888" }}>
<DateTimeFormatter>{job.estimate_sent_approval}</DateTimeFormatter>
</span>
)}
</Checkbox>
</Space>
</DataLabel>
<DataLabel label={t("jobs.fields.estimate_approved")}>
<Space>
<Checkbox
checked={!!job.estimate_approved}
onChange={(e) => handleCheckboxChange("estimate_approved", e.target.checked)}
disabled={disabled}
>
{job.estimate_approved && (
<span style={{ color: "#888" }}>
<DateTimeFormatter>{job.estimate_approved}</DateTimeFormatter>
</span>
)}
</Checkbox>
</Space>
</DataLabel>
<Space wrap>
{job.special_coverage_policy && (
<Tag color="tomato">
<Space>
<WarningFilled />
<span>{t("jobs.labels.specialcoveragepolicy")}</span>
</Space>
</Tag>
)}
{job.ca_gst_registrant && (
<Tag color="geekblue">
<Space>
<WarningFilled />
<span>{t("jobs.fields.ca_gst_registrant")}</span>
</Space>
</Tag>
)}
{job.hit_and_run && (
<Tag color="green">
<Space>
<WarningFilled />
<span>{t("jobs.fields.hit_and_run")}</span>
</Space>
</Tag>
)}
</Space>
</div>
</Card>
</Col>
<Col {...colSpan}>
<Card
style={{ height: "100%" }}
title={
disabled ? (
<>{ownerTitle.length > 0 ? ownerTitle : t("owner.labels.noownerinfo")}</>
) : (
<Link to={`/manage/owners/${job.owner.id}`}>
{ownerTitle.length > 0 ? ownerTitle : t("owner.labels.noownerinfo")}
</Link>
)
}
>
<div>
<DataLabel key="2" label={t("jobs.fields.ownr_ph1")}>
{disabled ? (
<PhoneNumberFormatter>{job.ownr_ph1}</PhoneNumberFormatter>
) : (
<ChatOpenButton phone={job.ownr_ph1} jobid={job.id} />
)}
</DataLabel>
<DataLabel key="22" label={t("jobs.fields.ownr_ph2")}>
{disabled ? (
<PhoneNumberFormatter>{job.ownr_ph2}</PhoneNumberFormatter>
) : (
<ChatOpenButton phone={job.ownr_ph2} jobid={job.id} />
)}
</DataLabel>
<DataLabel key="3" 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")}>
{disabled ? (
<>{job.ownr_ea || ""}</>
) : job.ownr_ea ? (
<a href={`mailto:${job.ownr_ea}`}>{job.ownr_ea}</a>
) : null}
</DataLabel>
{job.owner?.tax_number && (
<DataLabel key="5" label={t("owners.fields.tax_number")}>
{job.owner?.tax_number || ""}
</DataLabel>
)}
<DataLabel label={t("owners.fields.note")} valueStyle={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{job.owner?.note || ""}
</DataLabel>
</div>
</Card>
</Col>
<Col {...colSpan}>
<Card
style={{ height: "100%" }}
title={
job.vehicle ? (
</div>
</Card>
</Col>
<Col {...colSpan}>
<Card
style={{ height: "100%" }}
title={
disabled ? (
<>{vehicleTitle.length > 0 ? vehicleTitle : t("vehicles.labels.novehinfo")} </>
<>{ownerTitle.length > 0 ? ownerTitle : t("owner.labels.noownerinfo")}</>
) : (
<Link to={job.vehicle && `/manage/vehicles/${job.vehicle.id}`}>
{vehicleTitle.length > 0 ? vehicleTitle : t("vehicles.labels.novehinfo")}
<Link to={`/manage/owners/${job.owner.id}`}>
{ownerTitle.length > 0 ? ownerTitle : t("owner.labels.noownerinfo")}
</Link>
)
) : (
<span></span>
)
}
>
<div>
<DataLabel key="2" label={t("vehicles.fields.plate_no")}>
{`${job.plate_no || t("general.labels.na")} (${`${job.plate_st || t("general.labels.na")}`})`}
</DataLabel>
<DataLabel key="4" label={t("vehicles.fields.v_vin")}>
<VehicleVinDisplay>{`${job.v_vin || t("general.labels.na")}`}</VehicleVinDisplay>
{bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid ? (
job.v_vin?.length !== 17 ? (
<WarningFilled style={{ color: "tomato", marginLeft: ".3rem" }} />
) : null
) : null}
</DataLabel>
<DataLabel label={t("jobs.fields.regie_number")}>{job.regie_number || t("general.labels.na")}</DataLabel>
<DataLabel label={t("jobs.labels.relatedros")}>
<JobsRelatedRos jobid={job.id} job={job} disabled={disabled} />
</DataLabel>
{job.vehicle && job.vehicle.notes && (
<DataLabel
label={t("vehicles.fields.notes")}
valueStyle={{ whiteSpace: "pre-wrap" }}
valueClassName={notesClamped ? "clamp" : ""}
onValueClick={() => setNotesClamped(!notesClamped)}
>
{job.vehicle.notes}
}
>
<div>
<DataLabel key="2" label={t("jobs.fields.ownr_ph1")}>
{disabled ? (
<PhoneNumberFormatter>{job.ownr_ph1}</PhoneNumberFormatter>
) : (
<ChatOpenButton phone={job.ownr_ph1} jobid={job.id} />
)}
</DataLabel>
)}
{job.vehicle && job.vehicle.v_paint_codes && (
<DataLabel label={t("vehicles.fields.v_paint_codes", { number: "" })}>
<span style={{ whiteSpace: "pre" }}>
{Object.keys(job.vehicle.v_paint_codes)
.filter(
(key) =>
job.vehicle.v_paint_codes[key] !== "" &&
job.vehicle.v_paint_codes[key] !== null &&
job.vehicle.v_paint_codes[key] !== undefined
)
.map((key, idx) => (
<Tag key={idx}>{job.vehicle.v_paint_codes[key]}</Tag>
))}
</span>
<DataLabel key="22" label={t("jobs.fields.ownr_ph2")}>
{disabled ? (
<PhoneNumberFormatter>{job.ownr_ph2}</PhoneNumberFormatter>
) : (
<ChatOpenButton phone={job.ownr_ph2} jobid={job.id} />
)}
</DataLabel>
)}
</div>
</Card>
</Col>
<Col {...colSpan}>
<Card style={{ height: "100%" }} title={t("jobs.labels.employeeassignments")}>
<div>
<JobEmployeeAssignments job={job} />
<Divider style={{ margin: ".5rem" }} />
<DataLabel label={t("jobs.labels.labor_hrs")}>
{bodyHrs.toFixed(1)} / {refinishHrs.toFixed(1)} / {(bodyHrs + refinishHrs).toFixed(1)}
</DataLabel>
</div>
</Card>
</Col>
</Row>
<DataLabel key="3" 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")}>
{disabled ? (
<>{job.ownr_ea || ""}</>
) : job.ownr_ea ? (
<a href={`mailto:${job.ownr_ea}`}>{job.ownr_ea}</a>
) : null}
</DataLabel>
{job.owner?.tax_number && (
<DataLabel key="5" label={t("owners.fields.tax_number")}>
{job.owner?.tax_number || ""}
</DataLabel>
)}
<DataLabel label={t("owners.fields.note")} valueStyle={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{job.owner?.note || ""}
</DataLabel>
</div>
</Card>
</Col>
<Col {...colSpan}>
<Card
style={{ height: "100%" }}
title={
job.vehicle ? (
disabled ? (
<>{vehicleTitle.length > 0 ? vehicleTitle : t("vehicles.labels.novehinfo")} </>
) : (
<Link to={job.vehicle && `/manage/vehicles/${job.vehicle.id}`}>
{vehicleTitle.length > 0 ? vehicleTitle : t("vehicles.labels.novehinfo")}
</Link>
)
) : (
<span></span>
)
}
>
<div>
<DataLabel key="2" label={t("vehicles.fields.plate_no")}>
{`${job.plate_no || t("general.labels.na")} (${`${job.plate_st || t("general.labels.na")}`})`}
</DataLabel>
<DataLabel key="4" label={t("vehicles.fields.v_vin")}>
<VehicleVinDisplay>{`${job.v_vin || t("general.labels.na")}`}</VehicleVinDisplay>
{bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid ? (
job.v_vin?.length !== 17 ? (
<WarningFilled style={{ color: "tomato", marginLeft: ".3rem" }} />
) : null
) : null}
</DataLabel>
<DataLabel label={t("jobs.fields.regie_number")}>{job.regie_number || t("general.labels.na")}</DataLabel>
<DataLabel label={t("jobs.labels.relatedros")}>
<JobsRelatedRos jobid={job.id} job={job} disabled={disabled} />
</DataLabel>
{job.vehicle && job.vehicle.notes && (
<DataLabel
label={t("vehicles.fields.notes")}
valueStyle={{ whiteSpace: "pre-wrap" }}
valueClassName={notesClamped ? "clamp" : ""}
onValueClick={() => setNotesClamped(!notesClamped)}
>
{job.vehicle.notes}
</DataLabel>
)}
{job.vehicle && job.vehicle.v_paint_codes && (
<DataLabel label={t("vehicles.fields.v_paint_codes", { number: "" })}>
<span style={{ whiteSpace: "pre" }}>
{Object.keys(job.vehicle.v_paint_codes)
.filter(
(key) =>
job.vehicle.v_paint_codes[key] !== "" &&
job.vehicle.v_paint_codes[key] !== null &&
job.vehicle.v_paint_codes[key] !== undefined
)
.map((key, idx) => (
<Tag key={idx}>{job.vehicle.v_paint_codes[key]}</Tag>
))}
</span>
</DataLabel>
)}
</div>
</Card>
</Col>
<Col {...colSpan}>
<Card
style={{ height: "100%" }}
title={<span id="job-employee-assignments-title">{t("jobs.labels.employeeassignments")}</span>}
id={"job-employee-assignments"}
>
<div>
<JobEmployeeAssignments job={job} />
<Divider style={{ margin: ".5rem" }} />
<DataLabel label={t("jobs.labels.labor_hrs")}>
{bodyHrs.toFixed(1)} / {refinishHrs.toFixed(1)} / {(bodyHrs + refinishHrs).toFixed(1)}
</DataLabel>
</div>
</Card>
</Col>
</Row>
<PinnedJobNotes job={job} />
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ 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";
const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly
@@ -47,6 +48,9 @@ export function JobNotesComponent({
key: "icons",
width: 80,
filteredValue: filter?.icons || null,
defaultSortOrder: "desc",
multiple: 1,
sorter: (a, b) => a.pinned - b.pinned,
filters: [
{
text: t("notes.labels.usernotes"),
@@ -63,6 +67,7 @@ export function JobNotesComponent({
{record.critical ? <WarningFilled style={{ margin: 4, color: "red" }} /> : null}
{record.private ? <EyeInvisibleFilled style={{ margin: 4 }} /> : null}
{record.audit ? <AuditOutlined style={{ margin: 4 }} /> : null}
<JobNotesPinToggle note={record} />
</span>
)
},
@@ -100,6 +105,7 @@ export function JobNotesComponent({
dataIndex: "updated_at",
key: "updated_at",
defaultSortOrder: "descend",
multiple: 2,
width: 200,
sorter: (a, b) => new Date(a.updated_at) - new Date(b.updated_at),
render: (text, record) => <DateTimeFormatter>{record.updated_at}</DateTimeFormatter>

View File

@@ -23,17 +23,22 @@ export function NoteUpsertModalComponent({ form, noteUpsertModal }) {
return (
<>
<Row gutter={[16, 16]}>
<Col span={8}>
<Col span={6}>
<Form.Item label={t("notes.fields.critical")} name="critical" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={8}>
<Col span={6}>
<Form.Item label={t("notes.fields.private")} name="private" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={8}>
<Col span={6}>
<Form.Item label={t("notes.fields.pinned")} name="pinned" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item label={t("notes.fields.type")} name="type" initialValue="general">
<Select
options={[

View File

@@ -1,10 +1,12 @@
import { useMutation } from "@apollo/client";
import { useApolloClient, useMutation } from "@apollo/client";
import { Form, Modal } from "antd";
import React, { useEffect } from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { GET_JOB_BY_PK } from "../../graphql/jobs.queries.js";
import { INSERT_NEW_NOTE, UPDATE_NOTE } from "../../graphql/notes.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { toggleModalVisible } from "../../redux/modals/modals.actions";
@@ -12,7 +14,6 @@ import { selectNoteUpsert } from "../../redux/modals/modals.selectors";
import { selectCurrentUser } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import NoteUpsertModalComponent from "./note-upsert-modal.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
@@ -42,6 +43,8 @@ export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleM
const [form] = Form.useForm();
const { client } = useApolloClient();
useEffect(() => {
//Required to prevent infinite looping.
if (existingNote && open) {
@@ -65,8 +68,9 @@ export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleM
variables: {
noteId: existingNote.id,
note: values
}
}).then((r) => {
},
refetchQueries: ["GET_JOB_BY_PK", "QUERY_JOB_CARD_DETAILS", "QUERY_PARTS_QUEUE_CARD_DETAILS"]
}).then(() => {
notification["success"]({
message: t("notes.successes.updated")
});
@@ -86,6 +90,33 @@ export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleM
variables: {
noteInput: [{ ...values, jobid: jobId, created_by: currentUser.email }]
},
update(cache, { data: { updateNote: updatedNote } }) {
try {
const existingJob = cache.readQuery({
query: GET_JOB_BY_PK,
variables: { id: jobId }
});
if (existingJob) {
cache.writeQuery({
query: GET_JOB_BY_PK,
variables: { id: jobId },
data: {
...existingJob,
job: {
...existingJob.job,
notes: updatedNote.pinned
? [updatedNote, ...existingJob.job.notes]
: existingJob.job.notes.filter((n) => n.id !== updatedNote.id)
}
}
});
}
} catch (error) {
// Cache miss is okay, query hasn't been executed yet
console.log("Cache miss for GET_JOB_BY_PK");
}
},
refetchQueries: ["QUERY_NOTES_BY_JOB_PK"]
});

View File

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

View File

@@ -0,0 +1,30 @@
import { Card, Divider, Space } from "antd";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import JobNotesPinToggle from "../job-notes-pin-toggle/job-notes-pin-toggle.component";
function PinnedJobNotes({ job }) {
const { t } = useTranslation();
const pinnedNotes = useMemo(() => {
return job?.notes?.filter((note) => note.pinned); //This will be typically filtered, but adding this to maximize flexibility of the component.
}, [job.notes]);
return pinnedNotes?.length ? (
<>
<Divider />
<Space direction="vertical" style={{ width: "100%" }}>
{pinnedNotes?.map((note) => (
<Card
key={note.id}
title={`${t("notes.labels.pinned_note")} - ${t(`notes.fields.types.${note.type}`)}`}
extra={<JobNotesPinToggle note={note} />}
>
{note.text}
</Card>
))}
</Space>
</>
) : null;
}
export default PinnedJobNotes;

View File

@@ -59,6 +59,7 @@ const ret = {
"shop:dashboard": 3,
"shop:rbac": 5,
"shop:reportcenter": 2,
"shop:responsibilitycenter": 4, // Updated from "shop:responsibility" to "shop:responsibilitycenter"
"shop:templates": 4,
"shop:vendors": 2,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,16 @@
import { useMutation, useQuery } from "@apollo/client";
import { Form } from "antd";
import dayjs from "../../utils/day";
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { QUERY_BODYSHOP, UPDATE_SHOP } from "../../graphql/bodyshop.queries";
import dayjs from "../../utils/day";
import AlertComponent from "../alert/alert.component";
import FormsFieldChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import ShopInfoComponent from "./shop-info.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { FEATURE_CONFIGS, useFormDataPreservation } from "./useFormDataPreservation";
export default function ShopInfoContainer() {
const [form] = Form.useForm();
@@ -22,16 +23,24 @@ export default function ShopInfoContainer() {
});
const notification = useNotification();
const handleFinish = (values) => {
const combinedFeatureConfig = {
...FEATURE_CONFIGS.general,
...FEATURE_CONFIGS.responsibilitycenters
};
// Use form data preservation for all shop-info features
const { createSubmissionHandler } = useFormDataPreservation(form, data?.bodyshops[0], combinedFeatureConfig);
const handleFinish = createSubmissionHandler((values) => {
setSaveLoading(true);
logImEXEvent("shop_update");
updateBodyshop({
variables: { id: data.bodyshops[0].id, shop: values }
})
.then((r) => {
.then(() => {
notification["success"]({ message: t("bodyshop.successes.save") });
refetch().then((_) => form.resetFields());
refetch().then(() => form.resetFields());
})
.catch((error) => {
notification["error"]({
@@ -39,7 +48,7 @@ export default function ShopInfoContainer() {
});
});
setSaveLoading(false);
};
});
useEffect(() => {
if (data) form.resetFields();

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,140 @@
import { useEffect } from "react";
import { HasFeatureAccess } from "./../feature-wrapper/feature-wrapper.component";
/**
* Custom hook to preserve form data for conditionally hidden fields based on feature access
* @param {Object} form - Ant Design form instance
* @param {Object} bodyshop - Bodyshop data for feature access checks (also contains existing database values)
* @param {Object} featureConfig - Configuration object defining which features and their associated fields to preserve
*/
export const useFormDataPreservation = (form, bodyshop, featureConfig) => {
const getNestedValue = (obj, path) => {
return path.reduce((current, key) => current?.[key], obj);
};
const setNestedValue = (obj, path, value) => {
const lastKey = path[path.length - 1];
const parentPath = path.slice(0, -1);
const parent = parentPath.reduce((current, key) => {
if (!current[key]) current[key] = {};
return current[key];
}, obj);
parent[lastKey] = value;
};
const preserveHiddenFormData = () => {
const preservationData = {};
let hasDataToPreserve = false;
Object.entries(featureConfig).forEach(([featureName, fieldPaths]) => {
const hasAccess = HasFeatureAccess({ featureName, bodyshop });
if (!hasAccess) {
fieldPaths.forEach((fieldPath) => {
const currentValues = form.getFieldsValue();
let value = getNestedValue(currentValues, fieldPath);
if (value === undefined || value === null) {
value = getNestedValue(bodyshop, fieldPath);
}
if (value !== undefined && value !== null) {
setNestedValue(preservationData, fieldPath, value);
hasDataToPreserve = true;
}
});
}
});
if (hasDataToPreserve) {
form.setFieldsValue(preservationData);
}
};
const getCompleteFormValues = () => {
const currentFormValues = form.getFieldsValue();
const completeValues = { ...currentFormValues };
Object.entries(featureConfig).forEach(([featureName, fieldPaths]) => {
const hasAccess = HasFeatureAccess({ featureName, bodyshop });
if (!hasAccess) {
fieldPaths.forEach((fieldPath) => {
let value = getNestedValue(currentFormValues, fieldPath);
if (value === undefined || value === null) {
value = getNestedValue(bodyshop, fieldPath);
}
if (value !== undefined && value !== null) {
setNestedValue(completeValues, fieldPath, value);
}
});
}
});
return completeValues;
};
const createSubmissionHandler = (originalHandler) => {
return () => {
const completeValues = getCompleteFormValues();
// Call the original handler with complete values including hidden data
return originalHandler(completeValues);
};
};
useEffect(() => {
preserveHiddenFormData();
}, [bodyshop]);
return { preserveHiddenFormData, getCompleteFormValues, createSubmissionHandler };
};
/**
* Predefined feature configurations for common shop-info components
*/
export const FEATURE_CONFIGS = {
responsibilitycenters: {
export: [
["md_responsibility_centers", "costs"],
["md_responsibility_centers", "profits"],
["md_responsibility_centers", "defaults"],
["md_responsibility_centers", "dms_defaults"],
["md_responsibility_centers", "taxes", "itemexemptcode"],
["md_responsibility_centers", "taxes", "invoiceexemptcode"],
["md_responsibility_centers", "ar"],
["md_responsibility_centers", "refund"],
["md_responsibility_centers", "sales_tax_codes"],
["md_responsibility_centers", "ttl_adjustment"],
["md_responsibility_centers", "ttl_tax_adjustment"]
]
},
general: {
export: [
["accountingconfig", "qbo"],
["accountingconfig", "qbo_usa"],
["accountingconfig", "qbo_departmentid"],
["accountingconfig", "tiers"],
["accountingconfig", "twotierpref"],
["accountingconfig", "printlater"],
["accountingconfig", "emaillater"],
["accountingconfig", "ReceivableCustomField1"],
["accountingconfig", "ReceivableCustomField2"],
["accountingconfig", "ReceivableCustomField3"],
["md_classes"],
["enforce_class"],
["accountingconfig", "ClosingPeriod"],
["accountingconfig", "companyCode"],
["accountingconfig", "batchID"]
],
bills: [
["bill_tax_rates", "federal_tax_rate"],
["bill_tax_rates", "state_tax_rate"],
["bill_tax_rates", "local_tax_rate"]
],
timetickets: [["tt_allow_post_to_invoiced"], ["tt_enforce_hours_for_tech_console"], ["bill_allow_post_to_closed"]]
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery } from "@apollo/client";
import { Form, Modal } from "antd";
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";

View File

@@ -52,6 +52,7 @@ const VendorSearchSelect = ({ value, onChange, options, onSelect, disabled, pref
>
{label}
</div>
{discount && discount !== 0 ? <Tag color="green">{`${discount * 100}%`}</Tag> : null}
</div>
);
@@ -116,6 +117,11 @@ const VendorSearchSelect = ({ value, onChange, options, onSelect, disabled, pref
{o.name}
</div>
<Space style={{ marginLeft: "1rem" }}>
{o.tags?.map((tag, idx) => (
<Tag key={idx} style={{ marginLeft: "0.5rem" }}>
{tag}
</Tag>
))}
{o.phone && showPhone && <PhoneNumberFormatter>{o.phone}</PhoneNumberFormatter>}
{o.discount && o.discount !== 0 ? <Tag color="green">{`${o.discount * 100}%`}</Tag> : null}
</Space>

View File

@@ -1,7 +1,7 @@
import { DeleteFilled } from "@ant-design/icons";
import { useApolloClient } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Divider, Form, Input, InputNumber, Space, Switch } from "antd";
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from "antd";
import { PageHeader } from "@ant-design/pro-layout";
import React from "react";
import { useTranslation } from "react-i18next";
@@ -179,6 +179,18 @@ export function VendorsFormComponent({
}
</LayoutFormRow>
<Form.Item
name="tags"
label={t("vendor.fields.tags")}
rules={[
{
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>
{DmsAp.treatment === "on" && (
<Form.Item label={t("vendors.fields.dmsid")} name="dmsid">
<Input />

View File

@@ -1,5 +1,5 @@
import { SyncOutlined } from "@ant-design/icons";
import { Button, Card, Input, Space, Table } from "antd";
import { Button, Card, Input, Space, Table, Tag } from "antd";
import queryString from "query-string";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -38,6 +38,18 @@ export default function VendorsListComponent({ handleNewVendor, loading, handleO
title: t("vendors.fields.city"),
dataIndex: "city",
key: "city"
},
{
title: t("vendors.fields.tags"),
dataIndex: "tags",
key: "tags",
render: (text, record) => (
<Space>
{record?.tags?.map((tag, idx) => (
<Tag key={idx}>{tag}</Tag>
))}
</Space>
)
}
];

View File

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

View File

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

View File

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

View File

@@ -713,6 +713,19 @@ export const GET_JOB_BY_PK = gql`
v_model_yr
v_model_desc
v_vin
notes(where:{pinned: {_eq: true}}, order_by: {updated_at: desc}) {
created_at
created_by
critical
id
jobid
private
text
updated_at
audit
type
pinned
}
vehicle {
id
jobs {
@@ -959,6 +972,8 @@ export const QUERY_JOB_CARD_DETAILS = gql`
critical
private
created_at
pinned
type
}
updated_at
clm_total
@@ -984,6 +999,7 @@ export const QUERY_JOB_CARD_DETAILS = gql`
key
type
}
}
}
`;
@@ -1048,6 +1064,8 @@ export const QUERY_TECH_JOB_DETAILS = gql`
critical
private
created_at
pinned
type
}
updated_at
documents(order_by: { created_at: desc }) {
@@ -2323,7 +2341,7 @@ export const QUERY_JOBS_TECH_ASIGNED_TO_BY_TEAM = gql`
`;
export const QUERY_PARTS_QUEUE_CARD_DETAILS = gql`
query QUERY_JOB_CARD_DETAILS($id: uuid!) {
query QUERY_PARTS_QUEUE_CARD_DETAILS($id: uuid!) {
jobs_by_pk(id: $id) {
actual_completion
actual_delivery
@@ -2349,6 +2367,19 @@ export const QUERY_PARTS_QUEUE_CARD_DETAILS = gql`
start
status
}
notes(where:{pinned: {_eq: true}}, order_by: {updated_at: desc}) {
created_at
created_by
critical
id
jobid
private
text
updated_at
audit
type
pinned
}
clm_no
clm_total
comment

View File

@@ -14,6 +14,7 @@ export const INSERT_NEW_NOTE = gql`
updated_at
audit
type
pinned
}
}
}
@@ -43,6 +44,7 @@ export const QUERY_NOTES_BY_JOB_PK = gql`
updated_at
audit
type
pinned
}
}
}
@@ -63,6 +65,7 @@ export const UPDATE_NOTE = gql`
updated_at
audit
type
pinned
}
}
}

View File

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

View File

@@ -19,6 +19,7 @@ export const QUERY_VENDOR_BY_ID = gql`
active
phone
dmsid
tags
}
}
`;
@@ -54,6 +55,7 @@ export const QUERY_ALL_VENDORS = gql`
city
phone
active
tags
}
}
`;
@@ -89,6 +91,7 @@ export const QUERY_ALL_VENDORS_FOR_ORDER = gql`
email
active
phone
tags
}
jobs(where: { id: { _eq: $jobId } }) {
v_make_desc
@@ -105,6 +108,7 @@ export const SEARCH_VENDOR_AUTOCOMPLETE = gql`
cost_center
active
favorite
tags
}
}
`;
@@ -124,6 +128,7 @@ export const SEARCH_VENDOR_AUTOCOMPLETE_WITH_ADDR = gql`
email
state
active
tags
}
}
`;

View File

@@ -2,7 +2,7 @@ import { EditFilled, SyncOutlined } from "@ant-design/icons";
import { Button, Card, Checkbox, Input, Space, Table, Typography } from "antd";
import axios from "axios";
import queryString from "query-string";
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
@@ -13,8 +13,9 @@ import { setModalContext } from "../../redux/modals/modals.actions";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateFormatter } from "../../utils/DateFormatter";
import { TemplateList } from "../../utils/TemplateConstants";
import { alphaSort, dateSort } from "../../utils/sorters";
import { pageLimit } from "../../utils/config";
import { alphaSort, dateSort } from "../../utils/sorters";
import useLocalStorage from "../../utils/useLocalStorage";
const mapDispatchToProps = (dispatch) => ({
setPartsOrderContext: (context) => dispatch(setModalContext({ context: context, modal: "partsOrder" })),
@@ -27,7 +28,7 @@ export function BillsListPage({ loading, data, refetch, total, setPartsOrderCont
const [searchLoading, setSearchLoading] = useState(false);
const { page } = search;
const history = useNavigate();
const [state, setState] = useState({
const [state, setState] = useLocalStorage("bills_list_sort", {
sortedInfo: {},
filteredInfo: { text: "" }
});

View File

@@ -271,7 +271,7 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
{
required: true
},
({ getFieldValue }) => ({
() => ({
validator(_, value) {
if (!bodyshop.cdk_dealerid) return Promise.resolve();
if (!value || dayjs(value).isSameOrAfter(dayjs(), "day")) {
@@ -280,7 +280,7 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
return Promise.reject(new Error(t("jobs.labels.dms.invoicedatefuture")));
}
}),
({ getFieldValue }) => ({
() => ({
validator(_, value) {
if (ClosingPeriod.treatment === "on" && bodyshop.accountingconfig.ClosingPeriod) {
if (
@@ -369,8 +369,8 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
<Form.List
name={["qb_multiple_payers"]}
rules={[
({ getFieldValue }) => ({
validator(_, value) {
() => ({
validator() {
let totalAllocated = Dinero();
const payers = form.getFieldValue("qb_multiple_payers");
@@ -492,7 +492,7 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
<Statistic
title={t("jobs.labels.pimraryamountpayable")}
valueStyle={{
color: discrep.getAmount() > 0 ? "green" : "red"
color: discrep.getAmount() >= 0 ? "green" : "red"
}}
value={discrep.toFormat()}
/>

View File

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

View File

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

View File

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

View File

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

View File

@@ -426,6 +426,11 @@
"messagingtext": "Messaging Preset Text",
"noteslabel": "Note Label",
"notestext": "Note Text",
"notifications": {
"description": "Select employees to automatically follow new jobs and receive notifications for job updates.",
"invalid_followers": "Invalid selection. Please select valid employees.",
"placeholder": "Search for employees"
},
"partslocation": "Parts Location",
"phone": "Phone",
"prodtargethrs": "Production Target Hours",
@@ -512,6 +517,7 @@
"dashboard": "Shop -> Dashboard",
"rbac": "Shop -> RBAC",
"reportcenter": "Shop -> Report Center",
"responsibilitycenter": "Shop -> Responsibility Centers",
"templates": "Shop -> Templates",
"vendors": "Shop -> Vendors"
},
@@ -648,15 +654,9 @@
"use_paint_scale_data": "Use Paint Scale Data for Job Costing?",
"uselocalmediaserver": "Use Local Media Server?",
"website": "Website",
"zip_post": "Zip/Postal Code",
"notifications": {
"description": "Select employees to automatically follow new jobs and receive notifications for job updates.",
"placeholder": "Search for employees",
"invalid_followers": "Invalid selection. Please select valid employees."
}
"zip_post": "Zip/Postal Code"
},
"labels": {
"consent_settings": "Phone Number Opt-Out List",
"2tiername": "Name => RO",
"2tiersetup": "2 Tier Setup",
"2tiersource": "Source => RO",
@@ -667,6 +667,7 @@
"apptcolors": "Appointment Colors",
"businessinformation": "Business Information",
"checklists": "Checklists",
"consent_settings": "Phone Number Opt-Out List",
"csiq": "CSI Questions",
"customtemplates": "Custom Templates",
"defaultcostsmapping": "Default Costs Mapping",
@@ -704,6 +705,9 @@
"messagingpresets": "Messaging Presets",
"notemplatesavailable": "No templates available to add.",
"notespresets": "Notes Presets",
"notifications": {
"followers": "Notifications"
},
"orderstatuses": "Order Statuses",
"partslocations": "Parts Locations",
"partsscan": "Parts Scanning",
@@ -734,10 +738,7 @@
"ssbuckets": "Job Size Definitions",
"systemsettings": "System Settings",
"task-presets": "Task Presets",
"workingdays": "Working Days",
"notifications": {
"followers": "Notifications"
}
"workingdays": "Working Days"
},
"operations": {
"contains": "Contains",
@@ -783,6 +784,15 @@
"completed": "Job checklist completed."
}
},
"consent": {
"associated_owners": "Associated Owners",
"created_at": "Opt-Out Date",
"no_owners": "No Associated Owners",
"phone_1": "Phone 1",
"phone_2": "Phone 2",
"phone_number": "Phone Number",
"text_body": "Users can opt out of receiving SMS messages by replying with keywords such as STOP, UNSUBSCRIBE, CANCEL, END, QUIT, STOPALL, REVOKE and OPTOUT. To opt back in, users can reply with START, YES, or UNSTOP. Even after opting out, users can still send messages to us, which will be received and processed as needed. Ensure customers are informed to reply with these keywords to manage their messaging preferences. After opting out, users receive a confirmation message and will not receive further messages until they opt back in."
},
"contracts": {
"actions": {
"changerate": "Change Contract Rates",
@@ -975,7 +985,10 @@
"addcomponent": "Add Component"
},
"errors": {
"atp": "No Alt. Transport*",
"insco": "No Ins. Co.*",
"refreshrequired": "You must refresh the dashboard data to see this component.",
"status": "No Status*",
"updatinglayout": "Error saving updated layout {{message}}"
},
"labels": {
@@ -998,6 +1011,8 @@
"productiondollars": "Total Dollars in Production",
"productionhours": "Total Hours in Production",
"projectedmonthlysales": "Projected Monthly Sales",
"scheduleddeliverydate": "Scheduled Delivery Date: {{date}}",
"scheduleddeliverytoday": "Scheduled Delivery Today",
"scheduledindate": "Scheduled In Today: {{date}}",
"scheduledintoday": "Scheduled In Today",
"scheduledoutdate": "Scheduled Out Today: {{date}}",
@@ -1230,11 +1245,11 @@
"fcm": "You must allow notification permissions to have real time messaging. Click to try again.",
"notfound": "No record was found.",
"sizelimit": "The selected items exceed the size limit.",
"submit-for-testing": "Error submitting Job for testing.",
"sub_status": {
"expired": "The subscription for this shop has expired. Please contact Sales to reactivate.",
"trial-expired": "The trial for this shop has expired. Please contact Sales to reactivate."
}
},
"submit-for-testing": "Error submitting Job for testing."
},
"itemtypes": {
"contract": "CC Contract",
@@ -1654,8 +1669,6 @@
"adjustment_bottom_line": "Adjustments",
"adjustmenthours": "Adjustment Hours",
"alt_transport": "Alt. Trans.",
"estimate_sent_approval": "Estimate Sent for Approval",
"estimate_approved": "Estimate Approved",
"area_of_damage_impact": {
"10": "Left Front Side",
"11": "Left Front Corner",
@@ -1778,6 +1791,8 @@
"est_ct_ln": "Estimator Last Name",
"est_ea": "Estimator Email",
"est_ph1": "Estimator Phone #",
"estimate_approved": "Estimate Approved",
"estimate_sent_approval": "Estimate Sent for Approval",
"federal_tax_payable": "Federal Tax Payable",
"federal_tax_rate": "Federal Tax Rate",
"flat_rate_ats": "Flat Rate ATS?",
@@ -1961,8 +1976,6 @@
"scheddates": "Schedule Dates"
},
"labels": {
"sent": "",
"approved": "",
"accountsreceivable": "Accounts Receivable",
"act_price_ppc": "New Part Price",
"actual_completion_inferred": "$t(jobs.fields.actual_completion) inferred using $t(jobs.fields.scheduled_completion).",
@@ -1977,6 +1990,7 @@
"alreadyaddedtoscoreboard": "Job has already been added to scoreboard. Saving will update the previous entry.",
"alreadyclosed": "This Job has already been closed.",
"appointmentconfirmation": "Send confirmation to customer?",
"approved": "",
"associationwarning": "Any changes to associations will require updating the data from the new parent record to the Job.",
"audit": "Audit Trail",
"available": "Available",
@@ -2167,6 +2181,7 @@
"sales": "Sales",
"savebeforeconversion": "You have unsaved changes on the Job. Please save them before converting it. ",
"scheduledinchange": "The scheduled in is based off the latest appointment. To change this date, please schedule or reschedule the Job. ",
"sent": "",
"specialcoveragepolicy": "Special Coverage Policy Applies",
"state_tax_amt": "Provincial/State Taxes",
"subletsnotcompleted": "Outstanding Sublets",
@@ -2383,15 +2398,16 @@
},
"errors": {
"invalidphone": "The phone number is invalid. Unable to open conversation. ",
"no_consent": "This phone number has opted-out of Messaging.",
"noattachedjobs": "No Jobs have been associated to this conversation. ",
"updatinglabel": "Error updating label. {{error}}",
"no_consent": "This phone number has opted-out of Messaging."
"updatinglabel": "Error updating label. {{error}}"
},
"labels": {
"addlabel": "Add a label to this conversation.",
"archive": "Archive",
"maxtenimages": "You can only select up to a maximum of 10 images at a time.",
"messaging": "Messaging",
"no_consent": "Opted-out",
"noallowtxt": "This customer has not indicated their permission to be messaged.",
"nojobs": "Not associated to any Job.",
"nopush": "Polling Mode Enabled",
@@ -2401,8 +2417,7 @@
"selectmedia": "Select Media",
"sentby": "Sent by {{by}} at {{time}}",
"typeamessage": "Send a message...",
"unarchive": "Unarchive",
"no_consent": "Opted-out"
"unarchive": "Unarchive"
},
"render": {
"conversation_list": "Conversation List"
@@ -2422,6 +2437,7 @@
"fields": {
"createdby": "Created By",
"critical": "Critical",
"pinned": "Pinned",
"private": "Private",
"text": "Contents",
"type": "Type",
@@ -2440,6 +2456,7 @@
"addtorelatedro": "Add to Related ROs",
"newnoteplaceholder": "Add a note...",
"notetoadd": "Note to Add",
"pinned_note": "Pinned Note",
"systemnotes": "System Notes",
"usernotes": "User Notes"
},
@@ -2462,11 +2479,15 @@
"fcm": "Push"
},
"labels": {
"auto-add": "Automatically watch Jobs I import",
"auto-add-success": "Auto watcher status successfully changed.",
"auto-add-failure": "Something went wrong updating your auto watcher status.",
"add-watchers": "Add Watchers",
"add-watchers-team": "Add Team Members",
"auto-add": "Automatically watch Jobs I import",
"auto-add-description": "",
"auto-add-failure": "Something went wrong updating your auto watcher status.",
"auto-add-off": "",
"auto-add-on": "",
"auto-add-success": "Auto watcher status successfully changed.",
"employee-notification": "Notifications are disabled because you do not have an associated Employee record.",
"employee-search": "Search for an Employee",
"mark-all-read": "Mark All Read",
"new-notification-title": "New Notification:",
@@ -2483,8 +2504,7 @@
"teams-search": "Search for a Team",
"unwatch": "Unwatch",
"watch": "Watch",
"watching-issue": "Watching",
"employee-notification": "Notifications are disabled because you do not have an associated Employee record."
"watching-issue": "Watching"
},
"scenarios": {
"alternate-transport-changed": "Alternate Transport Changed",
@@ -3294,6 +3314,9 @@
"updated": "Scoreboard updated."
}
},
"settings": {
"title": "Phone Number Opt-Out List"
},
"tasks": {
"actions": {
"edit": "Edit Task",
@@ -3322,6 +3345,9 @@
"tomorrow": "Tomorrow",
"two_weeks": "Two Weeks"
},
"errors": {
"load_failure": "Failed to load Tasks."
},
"failures": {
"completed": "Failed to toggle Task completion.",
"created": "Failed to create Task.",
@@ -3356,6 +3382,16 @@
"remind_at": "Remind At",
"title": "Title"
},
"labels": {
"due_today": "Today",
"go_to_job": "Go to Job",
"my_tasks_center": "Task Center",
"no_due_date": "Incomplete",
"no_tasks": "No Tasks Found",
"overdue": "Overdue",
"ro-number": "RO #{{ro_number}}",
"upcoming": "Upcoming"
},
"placeholders": {
"assigned_to": "Select an Employee",
"billid": "Select a Bill",
@@ -3505,7 +3541,7 @@
"dashboard": "Dashboard",
"dms": "DMS Export",
"export-logs": "Export Logs",
"feature-request": "Feature Requet",
"feature-request": "Feature Request",
"inventory": "Inventory",
"jobs": "Jobs",
"jobs-active": "Active Jobs",
@@ -3855,6 +3891,7 @@
"state": "Province/State",
"street1": "Street",
"street2": "Address 2",
"tags": "Tags",
"taxid": "Tax ID",
"terms": "Payment Terms",
"zip": "Zip/Postal Code"
@@ -3871,18 +3908,6 @@
"validation": {
"unique_vendor_name": "You must enter a unique vendor name."
}
},
"consent": {
"phone_number": "Phone Number",
"associated_owners": "Associated Owners",
"created_at": "Opt-Out Date",
"no_owners": "No Associated Owners",
"phone_1": "Phone 1",
"phone_2": "Phone 2",
"text_body": "Users can opt out of receiving SMS messages by replying with keywords such as STOP, UNSUBSCRIBE, CANCEL, END, QUIT, STOPALL, REVOKE and OPTOUT. To opt back in, users can reply with START, YES, or UNSTOP. Even after opting out, users can still send messages to us, which will be received and processed as needed. Ensure customers are informed to reply with these keywords to manage their messaging preferences. After opting out, users receive a confirmation message and will not receive further messages until they opt back in."
},
"settings": {
"title": "Phone Number Opt-Out List"
}
}
}

View File

@@ -426,6 +426,11 @@
"messagingtext": "",
"noteslabel": "",
"notestext": "",
"notifications": {
"description": "",
"invalid_followers": "",
"placeholder": ""
},
"partslocation": "",
"phone": "",
"prodtargethrs": "",
@@ -512,6 +517,7 @@
"dashboard": "",
"rbac": "",
"reportcenter": "",
"responsibilitycenter": "",
"templates": "",
"vendors": ""
},
@@ -648,15 +654,9 @@
"use_paint_scale_data": "",
"uselocalmediaserver": "",
"website": "",
"zip_post": "",
"notifications": {
"description": "",
"placeholder": "",
"invalid_followers": ""
}
"zip_post": ""
},
"labels": {
"consent_settings": "",
"2tiername": "",
"2tiersetup": "",
"2tiersource": "",
@@ -667,6 +667,7 @@
"apptcolors": "",
"businessinformation": "",
"checklists": "",
"consent_settings": "",
"csiq": "",
"customtemplates": "",
"defaultcostsmapping": "",
@@ -704,6 +705,9 @@
"messagingpresets": "",
"notemplatesavailable": "",
"notespresets": "",
"notifications": {
"followers": ""
},
"orderstatuses": "",
"partslocations": "",
"partsscan": "",
@@ -734,10 +738,7 @@
"ssbuckets": "",
"systemsettings": "",
"task-presets": "",
"workingdays": "",
"notifications": {
"followers": ""
}
"workingdays": ""
},
"operations": {
"contains": "",
@@ -783,6 +784,15 @@
"completed": ""
}
},
"consent": {
"associated_owners": "",
"created_at": "",
"no_owners": "",
"phone_1": "",
"phone_2": "",
"phone_number": "",
"text_body": ""
},
"contracts": {
"actions": {
"changerate": "",
@@ -975,7 +985,10 @@
"addcomponent": ""
},
"errors": {
"atp": "",
"insco": "",
"refreshrequired": "",
"status": "",
"updatinglayout": ""
},
"labels": {
@@ -998,6 +1011,8 @@
"productiondollars": "",
"productionhours": "",
"projectedmonthlysales": "",
"scheduleddeliverydate": "",
"scheduleddeliverytoday": "",
"scheduledindate": "",
"scheduledintoday": "",
"scheduledoutdate": "",
@@ -1230,11 +1245,11 @@
"fcm": "",
"notfound": "",
"sizelimit": "",
"submit-for-testing": "",
"sub_status": {
"expired": "",
"trial-expired": ""
}
},
"submit-for-testing": ""
},
"itemtypes": {
"contract": "",
@@ -1646,8 +1661,6 @@
"voiding": ""
},
"fields": {
"estimate_sent_approval": "",
"estimate_approved": "",
"active_tasks": "",
"actual_completion": "Realización real",
"actual_delivery": "Entrega real",
@@ -1778,6 +1791,8 @@
"est_ct_ln": "Apellido del tasador",
"est_ea": "Correo electrónico del tasador",
"est_ph1": "Número de teléfono del tasador",
"estimate_approved": "",
"estimate_sent_approval": "",
"federal_tax_payable": "Impuesto federal por pagar",
"federal_tax_rate": "",
"flat_rate_ats": "",
@@ -1961,8 +1976,6 @@
"scheddates": ""
},
"labels": {
"sent": "",
"approved": "",
"accountsreceivable": "",
"act_price_ppc": "",
"actual_completion_inferred": "",
@@ -1977,6 +1990,7 @@
"alreadyaddedtoscoreboard": "",
"alreadyclosed": "",
"appointmentconfirmation": "¿Enviar confirmación al cliente?",
"approved": "",
"associationwarning": "",
"audit": "",
"available": "",
@@ -2167,6 +2181,7 @@
"sales": "",
"savebeforeconversion": "",
"scheduledinchange": "",
"sent": "",
"specialcoveragepolicy": "",
"state_tax_amt": "",
"subletsnotcompleted": "",
@@ -2383,15 +2398,16 @@
},
"errors": {
"invalidphone": "",
"no_consent": "",
"noattachedjobs": "",
"updatinglabel": "",
"no_consent": ""
"updatinglabel": ""
},
"labels": {
"addlabel": "",
"archive": "",
"maxtenimages": "",
"messaging": "Mensajería",
"no_consent": "",
"noallowtxt": "",
"nojobs": "",
"nopush": "",
@@ -2401,8 +2417,7 @@
"selectmedia": "",
"sentby": "",
"typeamessage": "Enviar un mensaje...",
"unarchive": "",
"no_consent": ""
"unarchive": ""
},
"render": {
"conversation_list": ""
@@ -2422,6 +2437,7 @@
"fields": {
"createdby": "Creado por",
"critical": "Crítico",
"pinned": "",
"private": "Privado",
"text": "Contenido",
"type": "",
@@ -2440,6 +2456,7 @@
"addtorelatedro": "",
"newnoteplaceholder": "Agrega una nota...",
"notetoadd": "",
"pinned_note": "",
"systemnotes": "",
"usernotes": ""
},
@@ -2462,13 +2479,15 @@
"fcm": ""
},
"labels": {
"auto-add-on": "",
"auto-add-off": "",
"auto-add-success": "",
"auto-add-failure": "",
"auto-add-description": "",
"add-watchers": "",
"add-watchers-team": "",
"auto-add": "",
"auto-add-description": "",
"auto-add-failure": "",
"auto-add-off": "",
"auto-add-on": "",
"auto-add-success": "",
"employee-notification": "",
"employee-search": "",
"mark-all-read": "",
"new-notification-title": "",
@@ -2485,8 +2504,7 @@
"teams-search": "",
"unwatch": "",
"watch": "",
"watching-issue": "",
"employee-notification": ""
"watching-issue": ""
},
"scenarios": {
"alternate-transport-changed": "",
@@ -3296,6 +3314,9 @@
"updated": ""
}
},
"settings": {
"title": ""
},
"tasks": {
"actions": {
"edit": "",
@@ -3324,6 +3345,9 @@
"tomorrow": "",
"two_weeks": ""
},
"errors": {
"load_failure": ""
},
"failures": {
"completed": "",
"created": "",
@@ -3358,6 +3382,16 @@
"remind_at": "",
"title": ""
},
"labels": {
"due_today": "",
"go_to_job": "",
"my_tasks_center": "",
"no_due_date": "",
"no_tasks": "",
"overdue": "",
"ro-number": "",
"upcoming": ""
},
"placeholders": {
"assigned_to": "",
"billid": "",
@@ -3857,6 +3891,7 @@
"state": "Provincia del estado",
"street1": "calle",
"street2": "Dirección 2",
"tags": "",
"taxid": "Identificación del impuesto",
"terms": "Términos de pago",
"zip": "código postal"
@@ -3873,18 +3908,6 @@
"validation": {
"unique_vendor_name": ""
}
},
"consent": {
"phone_number": "",
"associated_owners": "",
"created_at": "",
"no_owners": "",
"phone_1": "",
"phone_2": "",
"text_body": ""
},
"settings": {
"title": ""
}
}
}

View File

@@ -426,6 +426,11 @@
"messagingtext": "",
"noteslabel": "",
"notestext": "",
"notifications": {
"description": "",
"invalid_followers": "",
"placeholder": ""
},
"partslocation": "",
"phone": "",
"prodtargethrs": "",
@@ -512,6 +517,7 @@
"dashboard": "",
"rbac": "",
"reportcenter": "",
"responsibilitycenter": "",
"templates": "",
"vendors": ""
},
@@ -648,15 +654,9 @@
"use_paint_scale_data": "",
"uselocalmediaserver": "",
"website": "",
"zip_post": "",
"notifications": {
"description": "",
"placeholder": "",
"invalid_followers": ""
}
"zip_post": ""
},
"labels": {
"consent_settings": "",
"2tiername": "",
"2tiersetup": "",
"2tiersource": "",
@@ -667,6 +667,7 @@
"apptcolors": "",
"businessinformation": "",
"checklists": "",
"consent_settings": "",
"csiq": "",
"customtemplates": "",
"defaultcostsmapping": "",
@@ -704,6 +705,9 @@
"messagingpresets": "",
"notemplatesavailable": "",
"notespresets": "",
"notifications": {
"followers": ""
},
"orderstatuses": "",
"partslocations": "",
"partsscan": "",
@@ -734,10 +738,7 @@
"ssbuckets": "",
"systemsettings": "",
"task-presets": "",
"workingdays": "",
"notifications": {
"followers": ""
}
"workingdays": ""
},
"operations": {
"contains": "",
@@ -783,6 +784,15 @@
"completed": ""
}
},
"consent": {
"associated_owners": "Associated Owners",
"created_at": "Opt-Out Date",
"no_owners": "No Associated Owners",
"phone_1": "Phone 1",
"phone_2": "Phone 2",
"phone_number": "Phone Number",
"text_body": ""
},
"contracts": {
"actions": {
"changerate": "",
@@ -975,7 +985,10 @@
"addcomponent": ""
},
"errors": {
"atp": "",
"insco": "",
"refreshrequired": "",
"status": "",
"updatinglayout": ""
},
"labels": {
@@ -998,6 +1011,8 @@
"productiondollars": "",
"productionhours": "",
"projectedmonthlysales": "",
"scheduleddeliverydate": "",
"scheduleddeliverytoday": "",
"scheduledindate": "",
"scheduledintoday": "",
"scheduledoutdate": "",
@@ -1230,11 +1245,11 @@
"fcm": "",
"notfound": "",
"sizelimit": "",
"submit-for-testing": "",
"sub_status": {
"expired": "",
"trial-expired": ""
}
},
"submit-for-testing": ""
},
"itemtypes": {
"contract": "",
@@ -1646,8 +1661,6 @@
"voiding": ""
},
"fields": {
"estimate_sent_approval": "",
"estimate_approved": "",
"active_tasks": "",
"actual_completion": "Achèvement réel",
"actual_delivery": "Livraison réelle",
@@ -1778,6 +1791,8 @@
"est_ct_ln": "Nom de l'évaluateur",
"est_ea": "Courriel de l'évaluateur",
"est_ph1": "Numéro de téléphone de l'évaluateur",
"estimate_approved": "",
"estimate_sent_approval": "",
"federal_tax_payable": "Impôt fédéral à payer",
"federal_tax_rate": "",
"flat_rate_ats": "",
@@ -1961,8 +1976,6 @@
"scheddates": ""
},
"labels": {
"sent": "",
"approved": "",
"accountsreceivable": "",
"act_price_ppc": "",
"actual_completion_inferred": "",
@@ -1977,6 +1990,7 @@
"alreadyaddedtoscoreboard": "",
"alreadyclosed": "",
"appointmentconfirmation": "Envoyer une confirmation au client?",
"approved": "",
"associationwarning": "",
"audit": "",
"available": "",
@@ -2167,6 +2181,7 @@
"sales": "",
"savebeforeconversion": "",
"scheduledinchange": "",
"sent": "",
"specialcoveragepolicy": "",
"state_tax_amt": "",
"subletsnotcompleted": "",
@@ -2383,15 +2398,16 @@
},
"errors": {
"invalidphone": "",
"no_consent": "",
"noattachedjobs": "",
"updatinglabel": "",
"no_consent": ""
"updatinglabel": ""
},
"labels": {
"addlabel": "",
"archive": "",
"maxtenimages": "",
"messaging": "Messagerie",
"no_consent": "",
"noallowtxt": "",
"nojobs": "",
"nopush": "",
@@ -2401,8 +2417,7 @@
"selectmedia": "",
"sentby": "",
"typeamessage": "Envoyer un message...",
"unarchive": "",
"no_consent": ""
"unarchive": ""
},
"render": {
"conversation_list": ""
@@ -2422,6 +2437,7 @@
"fields": {
"createdby": "Créé par",
"critical": "Critique",
"pinned": "",
"private": "privé",
"text": "Contenu",
"type": "",
@@ -2440,6 +2456,7 @@
"addtorelatedro": "",
"newnoteplaceholder": "Ajouter une note...",
"notetoadd": "",
"pinned_note": "",
"systemnotes": "",
"usernotes": ""
},
@@ -2462,13 +2479,15 @@
"fcm": ""
},
"labels": {
"auto-add-on": "",
"auto-add-off": "",
"auto-add-success": "",
"auto-add-failure": "",
"auto-add-description": "",
"add-watchers": "",
"add-watchers-team": "",
"auto-add": "",
"auto-add-description": "",
"auto-add-failure": "",
"auto-add-off": "",
"auto-add-on": "",
"auto-add-success": "",
"employee-notification": "",
"employee-search": "",
"mark-all-read": "",
"new-notification-title": "",
@@ -2485,8 +2504,7 @@
"teams-search": "",
"unwatch": "",
"watch": "",
"watching-issue": "",
"employee-notification": ""
"watching-issue": ""
},
"scenarios": {
"alternate-transport-changed": "",
@@ -3296,6 +3314,9 @@
"updated": ""
}
},
"settings": {
"title": ""
},
"tasks": {
"actions": {
"edit": "",
@@ -3324,6 +3345,9 @@
"tomorrow": "",
"two_weeks": ""
},
"errors": {
"load_failure": ""
},
"failures": {
"completed": "",
"created": "",
@@ -3358,6 +3382,16 @@
"remind_at": "",
"title": ""
},
"labels": {
"due_today": "",
"go_to_job": "",
"my_tasks_center": "",
"no_due_date": "",
"no_tasks": "",
"overdue": "",
"ro-number": "",
"upcoming": ""
},
"placeholders": {
"assigned_to": "",
"billid": "",
@@ -3857,6 +3891,7 @@
"state": "Etat / Province",
"street1": "rue",
"street2": "Adresse 2 ",
"tags": "",
"taxid": "Identifiant de taxe",
"terms": "Modalités de paiement",
"zip": "Zip / code postal"
@@ -3873,18 +3908,6 @@
"validation": {
"unique_vendor_name": ""
}
},
"consent": {
"phone_number": "Phone Number",
"associated_owners": "Associated Owners",
"created_at": "Opt-Out Date",
"no_owners": "No Associated Owners",
"phone_1": "Phone 1",
"phone_2": "Phone 2",
"text_body": ""
},
"settings": {
"title": ""
}
}
}

View File

@@ -48,24 +48,24 @@ export default async function RenderTemplate(
...(renderAsHtml
? {}
: {
recipe: "chrome-pdf",
...(!ignoreCustomMargins && {
chrome: {
marginTop:
bodyshop.logo_img_path &&
recipe: "chrome-pdf",
...(!ignoreCustomMargins && {
chrome: {
marginTop:
bodyshop.logo_img_path &&
bodyshop.logo_img_path.headerMargin &&
bodyshop.logo_img_path.headerMargin > 36
? bodyshop.logo_img_path.headerMargin
: "36px",
marginBottom:
bodyshop.logo_img_path &&
? bodyshop.logo_img_path.headerMargin
: "36px",
marginBottom:
bodyshop.logo_img_path &&
bodyshop.logo_img_path.footerMargin &&
bodyshop.logo_img_path.footerMargin > 50
? bodyshop.logo_img_path.footerMargin
: "50px"
}
})
}),
? bodyshop.logo_img_path.footerMargin
: "50px"
}
})
}),
...(renderAsExcel ? { recipe: "html-to-xlsx" } : {}),
...(renderAsText ? { recipe: "text" } : {})
},
@@ -100,14 +100,14 @@ export default async function RenderTemplate(
chrome: {
marginTop:
bodyshop.logo_img_path &&
bodyshop.logo_img_path.headerMargin &&
bodyshop.logo_img_path.headerMargin > 36
bodyshop.logo_img_path.headerMargin &&
bodyshop.logo_img_path.headerMargin > 36
? bodyshop.logo_img_path.headerMargin
: "36px",
marginBottom:
bodyshop.logo_img_path &&
bodyshop.logo_img_path.footerMargin &&
bodyshop.logo_img_path.footerMargin > 50
bodyshop.logo_img_path.footerMargin &&
bodyshop.logo_img_path.footerMargin > 50
? bodyshop.logo_img_path.footerMargin
: "50px"
}
@@ -182,22 +182,22 @@ export async function RenderTemplates(templateObjects, bodyshop, renderAsHtml =
...(renderAsHtml
? {}
: {
recipe: "chrome-pdf",
chrome: {
marginTop:
bodyshop.logo_img_path &&
recipe: "chrome-pdf",
chrome: {
marginTop:
bodyshop.logo_img_path &&
bodyshop.logo_img_path.headerMargin &&
bodyshop.logo_img_path.headerMargin > 36
? bodyshop.logo_img_path.headerMargin
: "36px",
marginBottom:
bodyshop.logo_img_path &&
? bodyshop.logo_img_path.headerMargin
: "36px",
marginBottom:
bodyshop.logo_img_path &&
bodyshop.logo_img_path.footerMargin &&
bodyshop.logo_img_path.footerMargin > 50
? bodyshop.logo_img_path.footerMargin
: "50px"
}
}),
? bodyshop.logo_img_path.footerMargin
: "50px"
}
}),
pdfOperations: [
{
template: {
@@ -213,14 +213,14 @@ export async function RenderTemplates(templateObjects, bodyshop, renderAsHtml =
chrome: {
marginTop:
bodyshop.logo_img_path &&
bodyshop.logo_img_path.headerMargin &&
bodyshop.logo_img_path.headerMargin > 36
bodyshop.logo_img_path.headerMargin &&
bodyshop.logo_img_path.headerMargin > 36
? bodyshop.logo_img_path.headerMargin
: "36px",
marginBottom:
bodyshop.logo_img_path &&
bodyshop.logo_img_path.footerMargin &&
bodyshop.logo_img_path.footerMargin > 50
bodyshop.logo_img_path.footerMargin &&
bodyshop.logo_img_path.footerMargin > 50
? bodyshop.logo_img_path.footerMargin
: "50px"
},
@@ -302,7 +302,6 @@ export const fetchFilterData = async ({ name }) => {
const jsReportFilters = await cleanAxios.get(`${server}/odata/assets?$filter=name eq '${name}.filters'`, {
headers: { Authorization: jsrAuth }
});
console.log("🚀 ~ fetchFilterData ~ jsReportFilters:", jsReportFilters);
let parsedFilterData;
let useShopSpecificTemplate = false;

View File

@@ -0,0 +1,38 @@
import { ExclamationCircleFilled } from "@ant-design/icons";
/**
* Priority Label Component
* @param priority
* @returns {Element}
* @constructor
*/
const PriorityLabel = ({ priority }) => {
switch (priority) {
case 1:
return (
<div>
High <ExclamationCircleFilled style={{ marginLeft: "5px", color: "red" }} />
</div>
);
case 2:
return (
<div>
Medium <ExclamationCircleFilled style={{ marginLeft: "5px", color: "yellow" }} />
</div>
);
case 3:
return (
<div>
Low <ExclamationCircleFilled style={{ marginLeft: "5px", color: "green" }} />
</div>
);
default:
return (
<div>
None <ExclamationCircleFilled style={{ marginLeft: "5px" }} />
</div>
);
}
};
export default PriorityLabel;

View File

@@ -4909,6 +4909,7 @@
- critical
- id
- jobid
- pinned
- private
- text
- type
@@ -4923,6 +4924,7 @@
- critical
- id
- jobid
- pinned
- private
- text
- type
@@ -4947,6 +4949,7 @@
- critical
- id
- jobid
- pinned
- private
- text
- type
@@ -6344,11 +6347,13 @@
- joblineid
- assigned_to
- due_date
- deleted
- partsorderid
- completed
- description
- billid
- title
- jobid
- priority
retry_conf:
interval_sec: 10
@@ -7118,6 +7123,7 @@
- state
- street1
- street2
- tags
- updated_at
- zip
select_permissions:
@@ -7141,6 +7147,7 @@
- state
- street1
- street2
- tags
- updated_at
- zip
filter:
@@ -7174,6 +7181,7 @@
- state
- street1
- street2
- tags
- updated_at
- zip
filter:

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."notes" add column "pinned" boolean
-- not null default 'false';

View File

@@ -0,0 +1,2 @@
alter table "public"."notes" add column "pinned" boolean
not null default 'false';

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."vendors" add column "tags" jsonb
-- not null default jsonb_build_array();

View File

@@ -0,0 +1,2 @@
alter table "public"."vendors" add column "tags" jsonb
not null default jsonb_build_array();

1655
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,29 +16,29 @@
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
},
"dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.826.0",
"@aws-sdk/client-elasticache": "^3.826.0",
"@aws-sdk/client-s3": "^3.826.0",
"@aws-sdk/client-secrets-manager": "^3.826.0",
"@aws-sdk/client-ses": "^3.826.0",
"@aws-sdk/credential-provider-node": "^3.826.0",
"@aws-sdk/lib-storage": "^3.826.0",
"@aws-sdk/s3-request-presigner": "^3.826.0",
"@aws-sdk/client-cloudwatch-logs": "^3.844.0",
"@aws-sdk/client-elasticache": "^3.844.0",
"@aws-sdk/client-s3": "^3.844.0",
"@aws-sdk/client-secrets-manager": "^3.844.0",
"@aws-sdk/client-ses": "^3.844.0",
"@aws-sdk/credential-provider-node": "^3.844.0",
"@aws-sdk/lib-storage": "^3.844.0",
"@aws-sdk/s3-request-presigner": "^3.844.0",
"@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0",
"archiver": "^7.0.1",
"aws4": "^1.13.2",
"axios": "^1.8.4",
"axios": "^1.10.0",
"better-queue": "^3.8.12",
"bullmq": "^5.53.2",
"chart.js": "^4.4.8",
"cloudinary": "^2.6.1",
"bullmq": "^5.56.4",
"chart.js": "^4.5.0",
"cloudinary": "^2.7.0",
"compression": "^1.8.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"crisp-status-reporter": "^1.2.2",
"dd-trace": "^5.55.0",
"dd-trace": "^5.58.0",
"dinero.js": "^1.9.1",
"dotenv": "^16.4.5",
"express": "^4.21.1",
@@ -56,7 +56,7 @@
"multer": "^1.4.5-lts.1",
"node-persist": "^4.0.4",
"nodemailer": "^6.10.0",
"phone": "^3.1.58",
"phone": "^3.1.62",
"query-string": "7.1.3",
"recursive-diff": "^1.0.9",
"rimraf": "^6.0.1",
@@ -65,22 +65,23 @@
"socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.5",
"ssh2-sftp-client": "^11.0.0",
"twilio": "^5.7.0",
"twilio": "^5.7.3",
"uuid": "^11.1.0",
"winston": "^3.17.0",
"winston-cloudwatch": "^6.3.0",
"xml2js": "^0.6.2",
"xmlbuilder2": "^3.1.1"
"xmlbuilder2": "^3.1.1",
"yazl": "^3.3.1"
},
"devDependencies": {
"@eslint/js": "^9.28.0",
"eslint": "^9.28.0",
"@eslint/js": "^9.31.0",
"eslint": "^9.31.0",
"eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0",
"mock-require": "^3.0.3",
"p-limit": "^3.1.0",
"prettier": "^3.5.3",
"supertest": "^7.1.1",
"vitest": "^3.2.3"
"prettier": "^3.6.2",
"supertest": "^7.1.3",
"vitest": "^3.2.4"
}
}

View File

@@ -144,6 +144,7 @@ const paymentRefund = async (req, res) => {
logger.log("intellipay-refund-success", "DEBUG", req.user?.email, null, {
requestOptions: options,
response: response?.data,
...logResponseMeta
});

View File

@@ -19,7 +19,7 @@ async function JobCosting(req, res) {
const client = req.userGraphQLClient;
//Uncomment for further testing
// logger.log("job-costing-start", "DEBUG", req.user.email, jobid, null);
logger.log("job-costing-start", "DEBUG", req.user.email, jobid, null);
try {
const resp = await client.setHeaders({ Authorization: BearerToken }).request(queries.QUERY_JOB_COSTING_DETAILS, {
@@ -47,9 +47,9 @@ async function JobCostingMulti(req, res) {
const client = req.userGraphQLClient;
//Uncomment for further testing
// logger.log("job-costing-multi-start", "DEBUG", req?.user?.email, null, {
// jobids
// });
logger.log("job-costing-multi-start", "DEBUG", req?.user?.email, null, {
jobids
});
try {
const resp = await client
@@ -589,7 +589,7 @@ function GenerateCostingData(job) {
amount: Math.round((job.storage_payable || 0) * 100)
});
}
//Is it a DMS Setup?
const selectedDmsAllocationConfig =
(job.bodyshop.md_responsibility_centers.dms_defaults &&

View File

@@ -8,6 +8,7 @@ const getLifecycleStatusColor = require("../utils/getLifecycleStatusColor");
const jobLifecycle = async (req, res) => {
// Grab the jobids and statuses from the request body
const { jobids, statuses } = req.body;
const { logger } = req;
if (!jobids) {
return res.status(400).json({
@@ -16,102 +17,118 @@ const jobLifecycle = async (req, res) => {
}
const jobIDs = _.isArray(jobids) ? jobids : [jobids];
const client = req.userGraphQLClient;
const resp = await client.request(queries.QUERY_TRANSITIONS_BY_JOBID, { jobids: jobIDs });
const transitions = resp.transitions;
logger.log("job-lifecycle-start", "DEBUG", req?.user?.email, null, {
jobids: jobIDs
});
try {
const client = req.userGraphQLClient;
const resp = await client.request(queries.QUERY_TRANSITIONS_BY_JOBID, { jobids: jobIDs });
const transitions = resp.transitions;
if (!transitions) {
return res.status(200).json({
jobIDs,
transitions: []
});
}
const transitionsByJobId = _.groupBy(resp.transitions, "jobid");
const groupedTransitions = {};
const allDurations = [];
for (let jobId in transitionsByJobId) {
let lifecycle = transitionsByJobId[jobId].map((transition) => {
transition.start_readable = transition.start ? moment(transition.start).fromNow() : "N/A";
transition.end_readable = transition.end ? moment(transition.end).fromNow() : "N/A";
if (transition.duration) {
transition.duration_seconds = Math.round(transition.duration / 1000);
transition.duration_minutes = Math.round(transition.duration_seconds / 60);
let duration = moment.duration(transition.duration);
transition.duration_readable = durationToHumanReadable(duration);
} else {
transition.duration_seconds = 0;
transition.duration_minutes = 0;
transition.duration_readable = "N/A";
}
return transition;
});
const durations = calculateStatusDuration(lifecycle, statuses);
groupedTransitions[jobId] = {
lifecycle,
durations
};
if (durations?.summations) {
allDurations.push(durations.summations);
}
}
const finalSummations = [];
const flatGroupedAllDurations = _.groupBy(allDurations.flat(), "status");
const finalStatusCounts = Object.keys(flatGroupedAllDurations).reduce((acc, status) => {
acc[status] = flatGroupedAllDurations[status].length;
return acc;
}, {});
// Calculate total value of all statuses
const finalTotal = Object.values(flatGroupedAllDurations).reduce((total, statusArr) => {
return total + statusArr.reduce((acc, curr) => acc + curr.value, 0);
}, 0);
Object.keys(flatGroupedAllDurations).forEach((status) => {
const value = flatGroupedAllDurations[status].reduce((acc, curr) => acc + curr.value, 0);
const humanReadable = durationToHumanReadable(moment.duration(value));
const percentage = finalTotal > 0 ? (value / finalTotal) * 100 : 0;
const color = getLifecycleStatusColor(status);
const roundedPercentage = `${Math.round(percentage)}%`;
const averageValue = _.size(jobIDs) > 0 ? value / jobIDs.length : 0;
const averageHumanReadable = durationToHumanReadable(moment.duration(averageValue));
finalSummations.push({
status,
value,
humanReadable,
percentage,
color,
roundedPercentage,
averageValue,
averageHumanReadable
});
});
if (!transitions) {
return res.status(200).json({
jobIDs,
transitions: []
});
}
const transitionsByJobId = _.groupBy(resp.transitions, "jobid");
const groupedTransitions = {};
const allDurations = [];
for (let jobId in transitionsByJobId) {
let lifecycle = transitionsByJobId[jobId].map((transition) => {
transition.start_readable = transition.start ? moment(transition.start).fromNow() : "N/A";
transition.end_readable = transition.end ? moment(transition.end).fromNow() : "N/A";
if (transition.duration) {
transition.duration_seconds = Math.round(transition.duration / 1000);
transition.duration_minutes = Math.round(transition.duration_seconds / 60);
let duration = moment.duration(transition.duration);
transition.duration_readable = durationToHumanReadable(duration);
} else {
transition.duration_seconds = 0;
transition.duration_minutes = 0;
transition.duration_readable = "N/A";
transition: groupedTransitions,
durations: {
jobs: jobIDs.length,
summations: finalSummations,
totalStatuses: finalSummations.length,
total: finalTotal,
statusCounts: finalStatusCounts,
humanReadable: durationToHumanReadable(moment.duration(finalTotal)),
averageValue: _.size(jobIDs) > 0 ? finalTotal / jobIDs.length : 0,
averageHumanReadable:
_.size(jobIDs) > 0
? durationToHumanReadable(moment.duration(finalTotal / jobIDs.length))
: durationToHumanReadable(moment.duration(0))
}
return transition;
});
const durations = calculateStatusDuration(lifecycle, statuses);
groupedTransitions[jobId] = {
lifecycle,
durations
};
if (durations?.summations) {
allDurations.push(durations.summations);
}
} catch (error) {
logger.log("job-lifecycle-error", "ERROR", req?.user?.email, null, {
jobids: jobIDs,
statuses: statuses ? JSON.stringify(statuses) : "N/A",
error: error.message
});
return res.status(500).json({
error: "Internal server error"
});
}
const finalSummations = [];
const flatGroupedAllDurations = _.groupBy(allDurations.flat(), "status");
const finalStatusCounts = Object.keys(flatGroupedAllDurations).reduce((acc, status) => {
acc[status] = flatGroupedAllDurations[status].length;
return acc;
}, {});
// Calculate total value of all statuses
const finalTotal = Object.values(flatGroupedAllDurations).reduce((total, statusArr) => {
return total + statusArr.reduce((acc, curr) => acc + curr.value, 0);
}, 0);
Object.keys(flatGroupedAllDurations).forEach((status) => {
const value = flatGroupedAllDurations[status].reduce((acc, curr) => acc + curr.value, 0);
const humanReadable = durationToHumanReadable(moment.duration(value));
const percentage = finalTotal > 0 ? (value / finalTotal) * 100 : 0;
const color = getLifecycleStatusColor(status);
const roundedPercentage = `${Math.round(percentage)}%`;
const averageValue = _.size(jobIDs) > 0 ? value / jobIDs.length : 0;
const averageHumanReadable = durationToHumanReadable(moment.duration(averageValue));
finalSummations.push({
status,
value,
humanReadable,
percentage,
color,
roundedPercentage,
averageValue,
averageHumanReadable
});
});
return res.status(200).json({
jobIDs,
transition: groupedTransitions,
durations: {
jobs: jobIDs.length,
summations: finalSummations,
totalStatuses: finalSummations.length,
total: finalTotal,
statusCounts: finalStatusCounts,
humanReadable: durationToHumanReadable(moment.duration(finalTotal)),
averageValue: _.size(jobIDs) > 0 ? finalTotal / jobIDs.length : 0,
averageHumanReadable:
_.size(jobIDs) > 0
? durationToHumanReadable(moment.duration(finalTotal / jobIDs.length))
: durationToHumanReadable(moment.duration(0))
}
});
};
module.exports = jobLifecycle;

View File

@@ -381,7 +381,7 @@ async function CalculateRatesTotals({ job, client }) {
if (item.mod_lbr_ty) {
//Check to see if it has 0 hours and a price instead.
if (item.mod_lb_hrs === 0 && item.act_price > 0 && item.lbr_op === "OP14") {
if (item.lbr_op === "OP14" && item.act_price > 0 && (!item.part_type || item.mod_lb_hrs === 0)) {
//Scenario where SGI may pay out hours using a part price.
if (!ret[item.mod_lbr_ty.toLowerCase()].total) {
ret[item.mod_lbr_ty.toLowerCase()].total = Dinero();

View File

@@ -314,7 +314,8 @@ function CalculateRatesTotals(ratesList) {
if (item.mod_lbr_ty) {
//Check to see if it has 0 hours and a price instead.
if (item.mod_lb_hrs === 0 && item.act_price > 0 && item.lbr_op === "OP14") {
//Extend for when there are hours and a price.
if (item.lbr_op === "OP14" && item.act_price > 0 && (!item.part_type || item.mod_lb_hrs === 0)) {
//Scenario where SGI may pay out hours using a part price.
if (!ret[item.mod_lbr_ty.toLowerCase()].total) {
ret[item.mod_lbr_ty.toLowerCase()].total = Dinero();

View File

@@ -20,6 +20,7 @@ const {
GET_DOCUMENTS_BY_IDS,
DELETE_MEDIA_DOCUMENTS
} = require("../graphql-client/queries");
const yazl = require("yazl");
const imgproxyBaseUrl = process.env.IMGPROXY_BASE_URL; // `https://u4gzpp5wm437dnm75qa42tvza40fguqr.lambda-url.ca-central-1.on.aws` //Direct Lambda function access to bypass CDN.
const imgproxySalt = process.env.IMGPROXY_SALT;
@@ -102,13 +103,7 @@ const getThumbnailUrls = async (req, res) => {
//<Cloudfront_to_lambda>/<hmac with SHA of entire request URI path (with base64 encoded URL if needed), beginning with un-encoded/un-hashed Salt>/<remainder of url - resize params >/< base 64 URL encoded to image path>
//When working with documents from Cloudinary, the URL does not include the extension.
let key;
if (/\.[^/.]+$/.test(document.key)) {
key = document.key;
} else {
key = `${document.key}.${document.extension.toLowerCase()}`;
}
let key = keyStandardize(document)
// Build the S3 path to the object.
const fullS3Path = `s3://${imgproxyDestinationBucket}/${key}`;
const base64UrlEncodedKeyString = base64UrlEncode(fullS3Path);
@@ -168,78 +163,73 @@ const getThumbnailUrls = async (req, res) => {
* @returns {Promise<*>}
*/
const downloadFiles = async (req, res) => {
//Given a series of document IDs or keys, generate a file (or a link) to download all images in bulk
const { jobId, billid, documentids } = req.body;
logger.log("imgproxy-download", "DEBUG", req.user?.email, jobId, { billid, jobId, documentids });
const client = req.userGraphQLClient;
let data;
try {
logger.log("imgproxy-download", "DEBUG", req.user?.email, jobId, { billid, jobId, documentids });
//Delayed as the key structure may change slightly from what it is currently and will require evaluating mobile components.
const client = req.userGraphQLClient;
//Query for the keys of the document IDs
const data = await client.request(GET_DOCUMENTS_BY_IDS, { documentIds: documentids });
//Using the Keys, get all the S3 links, zip them, and send back to the client.
const s3client = new S3Client({ region: InstanceRegion() });
const archiveStream = archiver("zip");
archiveStream.on("error", (error) => {
console.error("Archival encountered an error:", error);
throw new Error(error);
});
const passThrough = new stream.PassThrough();
archiveStream.pipe(passThrough);
for (const key of data.documents.map((d) => d.key)) {
const response = await s3client.send(
new GetObjectCommand({
Bucket: imgproxyDestinationBucket,
Key: key
})
);
archiveStream.append(response.Body, { name: path.basename(key) });
}
await archiveStream.finalize();
const archiveKey = `archives/${jobId || "na"}/archive-${new Date().toISOString()}.zip`;
const parallelUploads3 = new Upload({
client: s3client,
queueSize: 4, // optional concurrency configuration
leavePartsOnError: false, // optional manually handle dropped parts
params: { Bucket: imgproxyDestinationBucket, Key: archiveKey, Body: passThrough }
});
// Disabled progress logging for upload, uncomment if needed
// parallelUploads3.on("httpUploadProgress", (progress) => {
// console.log(progress);
// });
await parallelUploads3.done();
//Generate the presigned URL to download it.
const presignedUrl = await getSignedUrl(
s3client,
new GetObjectCommand({ Bucket: imgproxyDestinationBucket, Key: archiveKey }),
{ expiresIn: 360 }
);
return res.json({ success: true, url: presignedUrl });
//Iterate over them, build the link based on the media type, and return the array.
data = await client.request(GET_DOCUMENTS_BY_IDS, { documentIds: documentids });
} catch (error) {
logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobId, {
logger.log("imgproxy-download-error", "ERROR", req.user?.email, jobId, {
jobId,
billid,
message: error.message,
stack: error.stack
});
return res.status(400).json({ message: error.message });
}
return res.status(400).json({ message: error.message, stack: error.stack });
const s3client = new S3Client({ region: InstanceRegion() });
const zipfile = new yazl.ZipFile();
const filename = `archive-${jobId || "na"}-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`;
res.setHeader("Content-Type", "application/zip");
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
// Handle zipfile stream errors
zipfile.outputStream.on("error", (err) => {
logger.log("imgproxy-download-zipstream-error", "ERROR", req.user?.email, jobId, { message: err.message, stack: err.stack });
// Cannot send another response here, just destroy the connection
res.destroy(err);
});
zipfile.outputStream.pipe(res);
try {
for (const doc of data.documents) {
let key = keyStandardize(doc)
let response;
try {
response = await s3client.send(
new GetObjectCommand({
Bucket: imgproxyDestinationBucket,
Key: key
})
);
} catch (err) {
logger.log("imgproxy-download-s3-error", "ERROR", req.user?.email, jobId, { key, message: err.message, stack: err.stack });
// Optionally, skip this file or add a placeholder file in the zip
continue;
}
// Attach error handler to S3 stream
response.Body.on("error", (err) => {
logger.log("imgproxy-download-s3stream-error", "ERROR", req.user?.email, jobId, { key, message: err.message, stack: err.stack });
res.destroy(err);
});
zipfile.addReadStream(response.Body, path.basename(key));
}
zipfile.end();
} catch (error) {
logger.log("imgproxy-download-error", "ERROR", req.user?.email, jobId, {
jobId,
billid,
message: error.message,
stack: error.stack
});
// Cannot send another response here, just destroy the connection
res.destroy(error);
}
};
@@ -392,6 +382,15 @@ const moveFiles = async (req, res) => {
}
};
const keyStandardize = (doc) => {
if (/\.[^/.]+$/.test(doc.key)) {
return doc.key;
} else {
return `${doc.key}.${doc.extension.toLowerCase()}`;
}
};
module.exports = {
generateSignedUploadUrls,
getThumbnailUrls,

View File

@@ -50,7 +50,12 @@ const autoAddWatchers = async (req) => {
try {
// Fetch bodyshop data from Redis
const bodyshopData = await getBodyshopFromRedis(shopId);
const notificationFollowers = bodyshopData?.notification_followers || [];
let notificationFollowers = bodyshopData?.notification_followers;
// Bail if notification_followers is missing or not an array
if (!notificationFollowers || !Array.isArray(notificationFollowers)) {
return;
}
// Execute queries in parallel
const [notificationData, existingWatchersData] = await Promise.all([

View File

@@ -145,15 +145,70 @@ const handleNotesChange = async (req, res) =>
const handlePaymentsChange = async (req, res) =>
processNotificationEvent(req, res, "req.body.event.new.jobid", "Payments Changed Notification Event Handled.");
/**
* Handle task socket emit.
* @param req
*/
const handleTaskSocketEmit = (req) => {
const {
logger,
ioRedis,
ioHelpers: { getBodyshopRoom }
} = req;
const event = req.body.event;
const op = event.op;
let taskData;
let type;
let bodyshopId;
if (op === "INSERT") {
taskData = event.data.new;
if (taskData.deleted) {
logger.log("tasks-event-insert-deleted", "warn", "notifications", null, { id: taskData.id });
} else {
type = "task-created";
bodyshopId = taskData.bodyshopid;
}
} else if (op === "UPDATE") {
const newData = event.data.new;
const oldData = event.data.old;
taskData = newData;
bodyshopId = newData.bodyshopid;
if (newData.deleted && !oldData.deleted) {
type = "task-deleted";
taskData = { id: newData.id, assigned_to: newData.assigned_to };
} else if (!newData.deleted && oldData.deleted) {
type = "task-created";
} else if (!newData.deleted) {
type = "task-updated";
}
} else {
logger.log("tasks-event-unknown-op", "warn", "notifications", null, { op });
}
if (bodyshopId && ioRedis && type) {
const room = getBodyshopRoom(bodyshopId);
ioRedis.to(room).emit("bodyshop-message", { type, payload: taskData });
logger.log("tasks-event-emitted", "info", "notifications", null, { type, bodyshopId });
} else if (type) {
logger.log("tasks-event-missing-data", "error", "notifications", null, { bodyshopId, hasIo: !!ioRedis, type });
}
};
/**
* Handle tasks change notifications.
* Note: this also handles task center notifications.
*
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @returns {Promise<Object>} JSON response with a success message.
*/
const handleTasksChange = async (req, res) =>
const handleTasksChange = async (req, res) => {
// Handle Notification Event
processNotificationEvent(req, res, "req.body.event.new.jobid", "Tasks Notifications Event Handled.");
handleTaskSocketEmit(req);
};
/**
* Handle time tickets change notifications.

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