Compare commits

...

88 Commits

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

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

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

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

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

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

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

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

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

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

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

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -335,7 +335,11 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail })
</Card>
</Col>
<Col {...colSpan}>
<Card style={{ height: "100%" }} title={t("jobs.labels.employeeassignments")}>
<Card
style={{ height: "100%" }}
title={<span id="job-employee-assignments-title">{t("jobs.labels.employeeassignments")}</span>}
id={"job-employee-assignments"}
>
<div>
<JobEmployeeAssignments job={job} />
<Divider style={{ margin: ".5rem" }} />

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

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

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

@@ -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 (

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,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

@@ -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

@@ -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

@@ -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

@@ -975,7 +975,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 +1001,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}}",
@@ -3295,6 +3300,16 @@
}
},
"tasks": {
"labels": {
"my_tasks_center": "Task Center",
"go_to_job": "Go to Job",
"overdue": "Overdue",
"due_today": "Today",
"upcoming": "Upcoming",
"no_due_date": "Incomplete",
"ro-number": "RO #{{ro_number}}",
"no_tasks": "No Tasks Found"
},
"actions": {
"edit": "Edit Task",
"new": "New Task",
@@ -3309,6 +3324,9 @@
"myTasks": "Mine",
"refresh": "Refresh"
},
"errors": {
"load_failure": "Failed to load Tasks."
},
"date_presets": {
"completion": "Completion",
"day": "Day",
@@ -3505,7 +3523,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",

View File

@@ -975,7 +975,10 @@
"addcomponent": ""
},
"errors": {
"atp": "",
"insco": "",
"refreshrequired": "",
"status": "",
"updatinglayout": ""
},
"labels": {
@@ -998,6 +1001,8 @@
"productiondollars": "",
"productionhours": "",
"projectedmonthlysales": "",
"scheduleddeliverydate": "",
"scheduleddeliverytoday": "",
"scheduledindate": "",
"scheduledintoday": "",
"scheduledoutdate": "",
@@ -3297,6 +3302,16 @@
}
},
"tasks": {
"labels": {
"my_tasks_center": "",
"go_to_job": "",
"overdue": "",
"due_today": "",
"upcoming": "",
"no_due_date": "",
"ro-number": "",
"no_tasks": ""
},
"actions": {
"edit": "",
"new": "",
@@ -3311,6 +3326,9 @@
"myTasks": "",
"refresh": ""
},
"errors": {
"load_failure": ""
},
"date_presets": {
"completion": "",
"day": "",

View File

@@ -975,7 +975,10 @@
"addcomponent": ""
},
"errors": {
"atp": "",
"insco": "",
"refreshrequired": "",
"status": "",
"updatinglayout": ""
},
"labels": {
@@ -998,6 +1001,8 @@
"productiondollars": "",
"productionhours": "",
"projectedmonthlysales": "",
"scheduleddeliverydate": "",
"scheduleddeliverytoday": "",
"scheduledindate": "",
"scheduledintoday": "",
"scheduledoutdate": "",
@@ -3297,6 +3302,16 @@
}
},
"tasks": {
"labels": {
"my_tasks_center": "",
"go_to_job": "",
"overdue": "",
"due_today": "",
"upcoming": "",
"no_due_date": "",
"ro-number": "",
"no_tasks": ""
},
"actions": {
"edit": "",
"new": "",
@@ -3311,6 +3326,9 @@
"myTasks": "",
"refresh": ""
},
"errors": {
"load_failure": ""
},
"date_presets": {
"completion": "",
"day": "",

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

@@ -6344,11 +6344,13 @@
- joblineid
- assigned_to
- due_date
- deleted
- partsorderid
- completed
- description
- billid
- title
- jobid
- priority
retry_conf:
interval_sec: 10

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

@@ -35,7 +35,7 @@ exports.default = async (req, res) => {
//Query the usage data.
const queryResults = await client.request(queries.STATUS_UPDATE, {
today: moment().startOf("day").subtract(7, "days"),
period: moment().subtract(90, "days").startOf("day")
period: moment().subtract(365, "days").startOf("day")
});
//Massage the data.
@@ -66,7 +66,7 @@ exports.default = async (req, res) => {
Usage Report for ${moment().format("MM/DD/YYYY")} for Rome Online Customers.
Notes:
- Days Since Creation: The number of days since the shop was created. Only shops created in the last 90 days are included.
- Days Since Creation: The number of days since the shop was created. Only shops created in the last 365 days are included.
- Updated values should be higher than created values.
- Counts are inclusive of the last 7 days of data.
`,

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.

View File

@@ -26,6 +26,7 @@ const redisSocketEvents = ({
try {
const user = await admin.auth().verifyIdToken(token);
socket.user = user;
socket.bodyshopId = bodyshopId;
await addUserSocketMapping(user.email, socket.id, bodyshopId);
next();
} catch (error) {
@@ -55,12 +56,8 @@ const redisSocketEvents = ({
return;
}
socket.user = user;
socket.bodyshopId = bodyshopId;
await refreshUserSocketTTL(user.email, bodyshopId);
createLogEvent(
socket,
"debug",
`Token updated successfully for socket ID: ${socket.id} (bodyshop: ${bodyshopId})`
);
socket.emit("token-updated", { success: true });
} catch (error) {
if (error.code === "auth/id-token-expired") {
@@ -82,7 +79,6 @@ const redisSocketEvents = ({
try {
const room = getBodyshopRoom(bodyshopUUID);
socket.join(room);
// createLogEvent(socket, "debug", `Client joined bodyshop room: ${room}`);
} catch (error) {
createLogEvent(socket, "error", `Error joining room: ${error}`);
}
@@ -92,7 +88,6 @@ const redisSocketEvents = ({
try {
const room = getBodyshopRoom(bodyshopUUID);
socket.leave(room);
createLogEvent(socket, "debug", `Client left bodyshop room: ${room}`);
} catch (error) {
createLogEvent(socket, "error", `Error joining room: ${error}`);
}
@@ -102,8 +97,6 @@ const redisSocketEvents = ({
try {
const room = getBodyshopRoom(bodyshopUUID);
io.to(room).emit("bodyshop-message", message);
// We do not need this as these can be debugged live
// createLogEvent(socket, "debug", `Broadcast message to bodyshop ${room}`);
} catch (error) {
createLogEvent(socket, "error", `Error getting room: ${error}`);
}
@@ -200,11 +193,6 @@ const redisSocketEvents = ({
io.to(socketId).emit("sync-notification-read", { notificationId, timestamp });
}
});
createLogEvent(
socket,
"debug",
`Synced notification ${notificationId} read for ${userEmail} in bodyshop ${bodyshopId}`
);
}
} catch (error) {
createLogEvent(socket, "error", `Error syncing notification read: ${error.message}`);
@@ -223,7 +211,6 @@ const redisSocketEvents = ({
io.to(socketId).emit("sync-all-notifications-read", { timestamp });
}
});
createLogEvent(socket, "debug", `Synced all notifications read for ${email} in bodyshop ${bodyshopId}`);
}
} catch (error) {
createLogEvent(socket, "error", `Error syncing all notifications read: ${error.message}`);
@@ -231,12 +218,34 @@ const redisSocketEvents = ({
});
};
// Task Events
const registerTaskEvents = (socket) => {
socket.on("task-created", (payload) => {
if (!payload) return;
const room = getBodyshopRoom(socket.bodyshopId);
io.to(room).emit("bodyshop-message", { type: "task-created", payload });
});
socket.on("task-updated", (payload) => {
if (!payload) return;
const room = getBodyshopRoom(socket.bodyshopId);
io.to(room).emit("bodyshop-message", { type: "task-updated", payload });
});
socket.on("task-deleted", (payload) => {
if (!payload || !payload.id) return;
const room = getBodyshopRoom(socket.bodyshopId);
io.to(room).emit("bodyshop-message", { type: "task-deleted", payload });
});
};
// Call Handlers
registerRoomAndBroadcastEvents(socket);
registerUpdateEvents(socket);
registerMessagingEvents(socket);
registerDisconnectEvents(socket);
registerSyncEvents(socket);
registerTaskEvents(socket);
};
// Associate Middleware and Handlers