Compare commits

..

52 Commits

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

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

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

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

Approved-by: Patrick Fic
2025-07-14 23:51:09 +00:00
Dave Richer
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
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
65 changed files with 5274 additions and 2157 deletions

2
.gitignore vendored
View File

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

View File

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

File diff suppressed because it is too large Load Diff

195
client/package-lock.json generated
View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { Select } from "antd"; import { Select } from "antd";
import React, { forwardRef } from "react"; import { forwardRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import InstanceRenderMgr from "../../utils/instanceRenderMgr"; import InstanceRenderMgr from "../../utils/instanceRenderMgr";
@@ -43,7 +43,7 @@ const BillLineSearchSelect = ({ options, disabled, allowRemoved, ...restProps },
item.oem_partno ? ` - ${item.oem_partno}` : "" item.oem_partno ? ` - ${item.oem_partno}` : ""
}${item.alt_partno ? ` (${item.alt_partno})` : ""}`.trim(), }${item.alt_partno ? ` (${item.alt_partno})` : ""}`.trim(),
label: ( label: (
<div style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}> <div style={{ whiteSpace: "normal", wordBreak: "break-word" }}>
<span> <span>
{`${item.removed ? `(REMOVED) ` : ""}${item.line_desc}${ {`${item.removed ? `(REMOVED) ` : ""}${item.line_desc}${
item.oem_partno ? ` - ${item.oem_partno}` : "" 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 { Select } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const { Option } = Select; const { Option } = Select;
const ContractStatusComponent = ({ value, onChange }, ref) => { const ContractStatusComponent = ({ value, onChange }) => {
const [option, setOption] = useState(value); const [option, setOption] = useState(value);
const { t } = useTranslation(); const { t } = useTranslation();

View File

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

View File

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

View File

@@ -1,30 +1,33 @@
import i18next from "i18next"; import i18next from "i18next";
import DashboardTotalProductionDollars from "../dashboard-components/total-production-dollars/total-production-dollars.component.jsx"; import JobLifecycleDashboardComponent, {
import { JobLifecycleDashboardGQL
DashboardTotalProductionHours, } from "../dashboard-components/job-lifecycle/job-lifecycle-dashboard.component.jsx";
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 DashboardMonthlyEmployeeEfficiency, { import DashboardMonthlyEmployeeEfficiency, {
DashboardMonthlyEmployeeEfficiencyGql DashboardMonthlyEmployeeEfficiencyGql
} from "../dashboard-components/monthly-employee-efficiency/monthly-employee-efficiency.component.jsx"; } 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, { import DashboardScheduledInToday, {
DashboardScheduledInTodayGql DashboardScheduledInTodayGql
} from "../dashboard-components/scheduled-in-today/scheduled-in-today.component.jsx"; } from "../dashboard-components/scheduled-in-today/scheduled-in-today.component.jsx";
import DashboardScheduledOutToday, { import DashboardScheduledOutToday, {
DashboardScheduledOutTodayGql DashboardScheduledOutTodayGql
} from "../dashboard-components/scheduled-out-today/scheduled-out-today.component.jsx"; } from "../dashboard-components/scheduled-out-today/scheduled-out-today.component.jsx";
import JobLifecycleDashboardComponent, { import DashboardTotalProductionDollars from "../dashboard-components/total-production-dollars/total-production-dollars.component.jsx";
JobLifecycleDashboardGQL import {
} from "../dashboard-components/job-lifecycle/job-lifecycle-dashboard.component.jsx"; DashboardTotalProductionHours,
DashboardTotalProductionHoursGql
} from "../dashboard-components/total-production-hours/total-production-hours.component.jsx";
const componentList = { const componentList = {
ProductionDollars: { ProductionDollars: {
@@ -118,6 +121,15 @@ const componentList = {
w: 10, w: 10,
h: 3 h: 3
}, },
ScheduleDeliveryToday: {
label: i18next.t("dashboard.titles.scheduleddeliverytoday"),
component: DashboardScheduledDeliveryToday,
gqlFragment: DashboardScheduledDeliveryTodayGql,
minW: 6,
minH: 2,
w: 10,
h: 3
},
JobLifecycle: { JobLifecycle: {
label: i18next.t("dashboard.titles.joblifecycle"), label: i18next.t("dashboard.titles.joblifecycle"),
component: JobLifecycleDashboardComponent, component: JobLifecycleDashboardComponent,

View File

@@ -1,6 +1,6 @@
import { DatePicker, Space, TimePicker } from "antd"; import { DatePicker, Space, TimePicker } from "antd";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import React, { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -94,7 +94,24 @@ const DateTimePicker = ({
showTime={false} showTime={false}
format="MM/DD/YYYY" format="MM/DD/YYYY"
value={value ? dayjs(value) : null} 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")} placeholder={t("general.labels.date")}
onBlur={handleBlur} onBlur={handleBlur}
disabledDate={handleDisabledDate} disabledDate={handleDisabledDate}
@@ -105,13 +122,25 @@ const DateTimePicker = ({
<TimePicker <TimePicker
format="hh:mm a" format="hh:mm a"
minuteStep={15} minuteStep={15}
value={value && dayjs(value).hour() === 0 && dayjs(value).minute() === 0 ? null : dayjs(value)}
defaultOpenValue={dayjs(value) defaultOpenValue={dayjs(value)
.hour(dayjs().hour()) .hour(dayjs().hour())
.minute(Math.floor(dayjs().minute() / 15) * 15) .minute(Math.floor(dayjs().minute() / 15) * 15)
.second(0)} .second(0)}
onChange={(value) => { onChange={(timeValue) => {
handleChange(value); if (timeValue) {
onBlur(); // 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")} placeholder={t("general.labels.time")}
{...restProps} {...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 { // noinspection RegExpAnonymousGroup
BankFilled,
BarChartOutlined, import { BellFilled } from "@ant-design/icons";
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";
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Badge, Layout, Menu, Spin } from "antd"; import { Badge, Layout, Menu, Spin, Tooltip } from "antd";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BsKanban } from "react-icons/bs"; import { FaTasks } from "react-icons/fa";
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 { connect } from "react-redux"; import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect"; 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 { 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 { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions"; import { setModalContext } from "../../redux/modals/modals.actions";
import { signOutStart } from "../../redux/user/user.actions"; import { signOutStart } from "../../redux/user/user.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import day from "../../utils/day.js"; import day from "../../utils/day.js";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { useIsEmployee } from "../../utils/useIsEmployee.js"; 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 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({ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser, currentUser: selectCurrentUser,
recentItems: selectRecentItems, recentItems: selectRecentItems,
@@ -73,36 +41,8 @@ const mapDispatchToProps = (dispatch) => ({
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" })) setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" }))
}); });
function Header({ // --- Utility Hooks ---
handleMenuClick, function useUnreadNotifications(userAssociationId, isConnected, scenarioNotificationsOn) {
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);
const { const {
data: unreadData, data: unreadData,
refetch: refetchUnread, refetch: refetchUnread,
@@ -128,633 +68,286 @@ function Header({
} }
}, [isConnected, unreadLoading, refetchUnread, userAssociationId]); }, [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(() => { useEffect(() => {
const updateTitle = () => { const updateTitle = () => {
const currentTitle = document.title; const currentTitle = document.title;
// Check if the current title differs from what we last set
if (currentTitle !== lastSetTitleRef.current) { if (currentTitle !== lastSetTitleRef.current) {
// Extract base title by removing any unread count prefix
const baseTitleMatch = currentTitle.match(/^\(\d+\)\s*(.*)$/); const baseTitleMatch = currentTitle.match(/^\(\d+\)\s*(.*)$/);
baseTitleRef.current = baseTitleMatch ? baseTitleMatch[1] : currentTitle; baseTitleRef.current = baseTitleMatch ? baseTitleMatch[1] : currentTitle;
} }
// Apply unread count to the base title
const newTitle = unreadCount > 0 ? `(${unreadCount}) ${baseTitleRef.current}` : baseTitleRef.current; 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) { if (document.title !== newTitle) {
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 document.addEventListener("mousedown", handleClickOutside);
updateTitle(); return () => document.removeEventListener("mousedown", handleClickOutside);
}, [taskCenterVisible, notificationVisible, scenarioNotificationsOn]);
// Poll every 100ms to catch child component changes // --- Event Handlers ---
const interval = setInterval(updateTitle, 100); const handleTaskCenterClick = useCallback(
(e) => {
setTaskCenterVisible((prev) => {
if (prev) return false;
return true;
});
if (handleMenuClick) handleMenuClick(e);
},
[handleMenuClick]
);
// Cleanup const handleNotificationClick = useCallback(
return () => { (e) => {
clearInterval(interval); setNotificationVisible((prev) => {
document.title = baseTitleRef.current; // Reset to base title on unmount if (prev) return false;
}; return true;
}, [unreadCount]); // Re-run when unreadCount changes });
if (handleMenuClick) handleMenuClick(e);
},
[handleMenuClick]
);
const handleNotificationClick = (e) => { // --- Menu Items ---
setNotificationVisible(!notificationVisible);
if (handleMenuClick) handleMenuClick(e);
};
const accountingChildren = [ // built externally to keep the component clean, but on this level to prevent unnecessary re-renders
{ const accountingChildren = useMemo(
key: "bills", () =>
id: "header-accounting-bills", buildAccountingChildren({
icon: <FaFileInvoiceDollar />, t,
label: ( bodyshop,
<Link to="/manage/bills"> currentUser,
<LockWrapper featureName="bills" bodyshop={bodyshop}> setBillEnterContext,
{t("menus.header.bills")} setPaymentContext,
</LockWrapper> setCardPaymentContext,
</Link> setTimeTicketContext,
) ImEXPay,
}, DmsAp,
{ Simple_Inventory
key: "enterbills", }),
id: "header-accounting-enterbills", [
icon: <GiPayMoney />, t,
label: ( bodyshop,
<LockWrapper featureName="bills" bodyshop={bodyshop}> currentUser,
{t("menus.header.enterbills")} setBillEnterContext,
</LockWrapper> setPaymentContext,
), setCardPaymentContext,
onClick: () => setTimeTicketContext,
HasFeatureAccess({ featureName: "bills", bodyshop }) && ImEXPay,
setBillEnterContext({ DmsAp,
actions: {}, Simple_Inventory
context: {} ]
}) );
},
...(Simple_Inventory.treatment === "on" // Built externally to keep the component clean
? [ const leftMenuItems = useMemo(
{ type: "divider" }, () =>
{ buildLeftMenuItems({
key: "inventory", t,
id: "header-accounting-inventory", bodyshop,
icon: <FaFileInvoiceDollar />, recentItems,
label: <Link to="/manage/inventory">{t("menus.header.inventory")}</Link> setTaskUpsertContext,
} setReportCenterContext,
] signOutStart,
: []), accountingChildren
{ type: "divider" }, }),
{ [t, bodyshop, recentItems, setTaskUpsertContext, setReportCenterContext, signOutStart, accountingChildren]
key: "allpayments", );
id: "header-accounting-allpayments",
icon: <BankFilled />, const rightMenuItems = useMemo(() => {
label: <Link to="/manage/payments">{t("menus.header.allpayments")}</Link> const items = [];
}, if (scenarioNotificationsOn) {
{ items.push({
key: "enterpayments", key: "notifications",
id: "header-accounting-enterpayments", id: "header-notifications",
icon: <FaCreditCard />, icon: unreadLoading ? (
label: t("menus.header.enterpayment"), <Spin size="small" />
onClick: () => ) : (
setPaymentContext({ <Badge offset={[8, 0]} size="small" count={isEmployee ? unreadCount : 0}>
actions: {}, <BellFilled />
context: null </Badge>
}) ),
}, onClick: handleNotificationClick
...(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>
)
}
]
} }
]; items.push({
key: "taskcenter",
// Left menu items (includes original navigation items) id: "header-taskcenter",
const leftMenuItems = [ icon: taskCountLoading ? (
{ <Spin size="small" />
key: "home", ) : (
id: "header-home", <Badge offset={[8, 0]} size="small" count={incompleteTaskCount > 0 ? incompleteTaskCount : 0} showZero={false}>
icon: <HomeFilled />, <Tooltip title={t("menus.header.tasks")}>
label: <Link to="/manage/">{t("menus.header.home")}</Link> <FaTasks />
}, </Tooltip>
{ </Badge>
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: [ onClick: handleTaskCenterClick
{ });
key: "courtesycarsall", return items;
id: "header-courtesycars-all", }, [
icon: <CarFilled />, scenarioNotificationsOn,
label: ( unreadLoading,
<Link to="/manage/courtesycars"> unreadCount,
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}> taskCountLoading,
{t("menus.header.courtesycars-all")} incompleteTaskCount,
</LockWrapper> isEmployee,
</Link> handleNotificationClick,
) handleTaskCenterClick,
}, t
{ ]);
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
}
]
: [];
// --- Render ---
return ( return (
<Layout.Header style={{ padding: 0, background: "#001529" }}> <Layout.Header style={{ padding: 0, background: "#001529" }}>
<div <div style={{ display: "flex", alignItems: "center", height: "100%", overflow: "hidden" }}>
style={{ <div style={{ flexGrow: 1, overflowX: "auto", whiteSpace: "nowrap" }}>
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 && (
<Menu <Menu
mode="horizontal" mode="horizontal"
theme="dark" theme="dark"
selectedKeys={[selectedHeader]} selectedKeys={[selectedHeader]}
onClick={handleMenuClick} onClick={handleMenuClick}
subMenuCloseDelay={0.3} subMenuCloseDelay={0.3}
items={notificationItem} items={leftMenuItems}
style={{ flex: "0 0 auto", minWidth: 0, borderBottom: "none", background: "transparent" }} 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> </div>
{scenarioNotificationsOn && ( {scenarioNotificationsOn && (
<NotificationCenterContainer <div ref={notificationRef}>
visible={notificationVisible} <NotificationCenterContainer
onClose={() => setNotificationVisible(false)} visible={notificationVisible}
unreadCount={unreadCount} onClose={() => setNotificationVisible(false)}
/> unreadCount={unreadCount}
/>
</div>
)} )}
<div ref={taskCenterRef}>
<TaskCenterContainer
incompleteTaskCount={incompleteTaskCount}
visible={taskCenterVisible}
onClose={() => setTaskCenterVisible(false)}
/>
</div>
</Layout.Header> </Layout.Header>
); );
} }

View File

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

View File

@@ -0,0 +1,52 @@
import { PushpinFilled, PushpinOutlined } from "@ant-design/icons";
import { useMutation } from "@apollo/client";
import { GET_JOB_BY_PK } from "../../graphql/jobs.queries";
import { UPDATE_NOTE } from "../../graphql/notes.queries";
function JobNotesPinToggle({ note }) {
const [updateNote] = useMutation(UPDATE_NOTE, {
update(cache, { data: { updateNote: updatedNote } }) {
try {
const existingJob = cache.readQuery({
query: GET_JOB_BY_PK,
variables: { id: note.jobid }
});
if (existingJob) {
cache.writeQuery({
query: GET_JOB_BY_PK,
variables: { id: note.jobid },
data: {
...existingJob,
job: {
...existingJob.job,
notes: updatedNote.pinned
? [updatedNote, ...existingJob.job.notes]
: existingJob.job.notes.filter((n) => n.id !== updatedNote.id)
}
}
});
}
} catch (error) {
// Query not yet executed is most likely. No logging as this isn't a fatal error.
}
}
});
const handlePinToggle = () => {
updateNote({
variables: {
noteId: note.id,
note: { pinned: !note.pinned }
}
});
};
return note.pinned ? (
<PushpinFilled size="large" onClick={handlePinToggle} style={{ color: "gold" }} />
) : (
<PushpinOutlined size="large" onClick={handlePinToggle} />
);
}
export default JobNotesPinToggle;

View File

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

View File

@@ -673,7 +673,9 @@ export function JobsDetailHeaderActions({
context: { context: {
jobId: job.id, jobId: job.id,
job: job, 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")} {t("menus.jobsactions.deletejob")}
</Popconfirm> </Popconfirm>
) : ( ) : (
<Popconfirm <Popconfirm title={t("jobs.labels.deletewatchers")} onClick={(e) => e.stopPropagation()} showCancel={false}>
title={t("jobs.labels.deletewatchers")}
onClick={(e) => e.stopPropagation()}
showCancel={false}
>
{t("menus.jobsactions.deletejob")} {t("menus.jobsactions.deletejob")}
</Popconfirm> </Popconfirm>
) )

View File

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

View File

@@ -3,7 +3,6 @@ import axios from "axios";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
import cleanAxios from "../../utils/CleanAxios";
import formatBytes from "../../utils/formatbytes"; import formatBytes from "../../utils/formatbytes";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -12,7 +11,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
@@ -26,7 +25,7 @@ const mapDispatchToProps = (dispatch) => ({
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyDownloadButton); export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyDownloadButton);
export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, identifier, jobId }) { export function JobsDocumentsImgproxyDownloadButton({ galleryImages, identifier, jobId }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [download, setDownload] = useState(null); const [download, setDownload] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import useLocalStorage from "../../utils/useLocalStorage";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import NoteUpsertModal from "../note-upsert-modal/note-upsert-modal.container"; import NoteUpsertModal from "../note-upsert-modal/note-upsert-modal.container";
import PrintWrapperComponent from "../print-wrapper/print-wrapper.component"; import PrintWrapperComponent from "../print-wrapper/print-wrapper.component";
import JobNotesPinToggle from "../job-notes-pin-toggle/job-notes-pin-toggle.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly jobRO: selectJobReadOnly
@@ -47,6 +48,9 @@ export function JobNotesComponent({
key: "icons", key: "icons",
width: 80, width: 80,
filteredValue: filter?.icons || null, filteredValue: filter?.icons || null,
defaultSortOrder: "desc",
multiple: 1,
sorter: (a, b) => a.pinned - b.pinned,
filters: [ filters: [
{ {
text: t("notes.labels.usernotes"), text: t("notes.labels.usernotes"),
@@ -63,6 +67,7 @@ export function JobNotesComponent({
{record.critical ? <WarningFilled style={{ margin: 4, color: "red" }} /> : null} {record.critical ? <WarningFilled style={{ margin: 4, color: "red" }} /> : null}
{record.private ? <EyeInvisibleFilled style={{ margin: 4 }} /> : null} {record.private ? <EyeInvisibleFilled style={{ margin: 4 }} /> : null}
{record.audit ? <AuditOutlined style={{ margin: 4 }} /> : null} {record.audit ? <AuditOutlined style={{ margin: 4 }} /> : null}
<JobNotesPinToggle note={record} />
</span> </span>
) )
}, },
@@ -100,6 +105,7 @@ export function JobNotesComponent({
dataIndex: "updated_at", dataIndex: "updated_at",
key: "updated_at", key: "updated_at",
defaultSortOrder: "descend", defaultSortOrder: "descend",
multiple: 2,
width: 200, width: 200,
sorter: (a, b) => new Date(a.updated_at) - new Date(b.updated_at), sorter: (a, b) => new Date(a.updated_at) - new Date(b.updated_at),
render: (text, record) => <DateTimeFormatter>{record.updated_at}</DateTimeFormatter> render: (text, record) => <DateTimeFormatter>{record.updated_at}</DateTimeFormatter>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,29 +1,172 @@
import dayjs from "../../utils/day"; 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) => export const WeeklyTargetHrs = (dailyTargetHrs) =>
dailyTargetHrs * CalculateWorkingDaysInPeriod(dayjs().startOf("week"), dayjs().endOf("week")); 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) => export const WeeklyTargetHrsInPeriod = (dailyTargetHrs, start, end) =>
dailyTargetHrs * CalculateWorkingDaysInPeriod(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(); 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(); 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 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 = () => { export const ListOfDaysInCurrentMonth = () => {
const days = []; const days = [];
let dateStart = dayjs().startOf("month"); let dateStart = dayjs().startOf("month");
@@ -36,6 +179,13 @@ export const ListOfDaysInCurrentMonth = () => {
return days; return days;
}; };
/**
* Generates a list of all days between two dates.
* @param start
* @param end
* @returns {*[]}
* @constructor
*/
export const ListDaysBetween = ({ start, end }) => { export const ListDaysBetween = ({ start, end }) => {
const days = []; const days = [];
let dateStart = dayjs(start); let dateStart = dayjs(start);

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, DeleteFilled,
DeleteOutlined, DeleteOutlined,
EditFilled, EditFilled,
ExclamationCircleFilled,
PlusCircleFilled, PlusCircleFilled,
SyncOutlined SyncOutlined
} from "@ant-design/icons"; } from "@ant-design/icons";
import { Button, Card, Space, Switch, Table } from "antd"; import { Button, Card, Space, Switch, Table } from "antd";
import queryString from "query-string"; import queryString from "query-string";
import React, { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom"; 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 { DateFormatter, DateTimeFormatter } from "../../utils/DateFormatter.jsx";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx"; import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
import PriorityLabel from "../../utils/tasksPriorityLabel.jsx";
/** /**
* Task List Component * 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) => ({ const mapDispatchToProps = (dispatch) => ({
// Existing dispatch props... // Existing dispatch props...
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" })) setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" }))
}); });
const mapStateToProps = (state) => ({}); const mapStateToProps = () => ({});
export default connect(mapStateToProps, mapDispatchToProps)(TaskListComponent); 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 { MUTATION_TOGGLE_TASK_COMPLETED, MUTATION_TOGGLE_TASK_DELETED } from "../../graphql/tasks.queries.js";
import { pageLimit } from "../../utils/config.js"; import { pageLimit } from "../../utils/config.js";
import AlertComponent from "../alert/alert.component.jsx"; import AlertComponent from "../alert/alert.component.jsx";
import React from "react";
import TaskListComponent from "./task-list.component.jsx"; import TaskListComponent from "./task-list.component.jsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect, useDispatch } from "react-redux"; import { connect, useDispatch } from "react-redux";
@@ -20,7 +19,7 @@ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser currentUser: selectCurrentUser
}); });
const mapDispatchToProps = (dispatch) => ({}); const mapDispatchToProps = () => ({});
export default connect(mapStateToProps, mapDispatchToProps)(TaskListContainer); export default connect(mapStateToProps, mapDispatchToProps)(TaskListContainer);
@@ -55,8 +54,8 @@ export function TaskListContainer({
bodyshop: bodyshop.id, bodyshop: bodyshop.id,
[relationshipType]: relationshipId, [relationshipType]: relationshipId,
deleted: deleted === "true", deleted: deleted === "true",
completed: completed === "true", //TODO: Find where mine is set. completed: completed === "true",
assigned_to: onlyMine ? bodyshop?.employees?.find((e) => e.user_email === currentUser.email)?.id : undefined, // replace currentUserID with the actual ID of the current user assigned_to: onlyMine ? bodyshop?.employees?.find((e) => e.user_email === currentUser.email)?.id : undefined,
offset: page ? (page - 1) * pageLimit : 0, offset: page ? (page - 1) * pageLimit : 0,
limit: pageLimit, limit: pageLimit,
order: [ order: [

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
// SocketProvider.js
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import SocketIO from "socket.io-client"; import SocketIO from "socket.io-client";
import { auth } from "../../firebase/firebase.utils"; import { auth } from "../../firebase/firebase.utils";
@@ -18,6 +17,8 @@ import { useTranslation } from "react-i18next";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { INITIAL_NOTIFICATIONS, SocketContext } from "./useSocket.js"; import { INITIAL_NOTIFICATIONS, SocketContext } from "./useSocket.js";
const LIMIT = INITIAL_NOTIFICATIONS;
/** /**
* Socket Provider - Scenario Notifications / Web Socket related items * Socket Provider - Scenario Notifications / Web Socket related items
* @param children * @param children
@@ -31,6 +32,7 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
const socketRef = useRef(null); const socketRef = useRef(null);
const [clientId, setClientId] = useState(null); const [clientId, setClientId] = useState(null);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [socketInitialized, setSocketInitialized] = useState(false);
const notification = useNotification(); const notification = useNotification();
const userAssociationId = bodyshop?.associations?.[0]?.id; const userAssociationId = bodyshop?.associations?.[0]?.id;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -146,6 +148,13 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
onError: (err) => console.error("MARK_ALL_NOTIFICATIONS_READ error:", err) onError: (err) => console.error("MARK_ALL_NOTIFICATIONS_READ error:", err)
}); });
const checkAndReconnect = () => {
if (socketRef.current && !socketRef.current.connected) {
console.log("Attempting manual reconnect due to event trigger");
socketRef.current.connect();
}
};
useEffect(() => { useEffect(() => {
const initializeSocket = async (token) => { const initializeSocket = async (token) => {
if (!bodyshop || !bodyshop.id || socketRef.current) return; if (!bodyshop || !bodyshop.id || socketRef.current) return;
@@ -164,18 +173,95 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
}); });
socketRef.current = socketInstance; socketRef.current = socketInstance;
setSocketInitialized(true);
const handleBodyshopMessage = (message) => { const handleBodyshopMessage = (message) => {
if (!message || !message.type) return; if (!message || !message.type) return;
switch (message.type) { switch (message.type) {
case "alert-update": case "alert-update":
store.dispatch(addAlerts(message.payload)); 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; break;
default: default:
break; break;
} }
}; };
const handleConnect = () => { const handleConnect = () => {
socketInstance.emit("join-bodyshop-room", bodyshop.id); socketInstance.emit("join-bodyshop-room", bodyshop.id);
setClientId(socketInstance.id); setClientId(socketInstance.id);
@@ -472,6 +558,57 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
t t
]); ]);
useEffect(() => {
if (!socketInitialized) return;
const onVisibilityChange = () => {
if (document.visibilityState === "visible") {
checkAndReconnect();
}
};
const onFocus = () => {
checkAndReconnect();
};
const onOnline = () => {
checkAndReconnect();
};
const onPageShow = (event) => {
if (event.persisted) {
checkAndReconnect();
}
};
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("focus", onFocus);
window.addEventListener("online", onOnline);
window.addEventListener("pageshow", onPageShow);
// Sleep/wake detection using timer
let lastTime = Date.now();
const intervalMs = 1000; // Check every second
const thresholdMs = 2000; // If more than 2 seconds elapsed, assume sleep/wake
const sleepCheckInterval = setInterval(() => {
const currentTime = Date.now();
if (currentTime > lastTime + intervalMs + thresholdMs) {
console.log("Detected potential wake from sleep/hibernate");
checkAndReconnect();
}
lastTime = currentTime;
}, intervalMs);
return () => {
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("focus", onFocus);
window.removeEventListener("online", onOnline);
window.removeEventListener("pageshow", onPageShow);
clearInterval(sleepCheckInterval);
};
}, [socketInitialized]);
return ( return (
<SocketContext.Provider <SocketContext.Provider
value={{ value={{

View File

@@ -3,6 +3,8 @@ import { createContext, useContext } from "react";
const SocketContext = createContext(null); const SocketContext = createContext(null);
const INITIAL_NOTIFICATIONS = 10; const INITIAL_NOTIFICATIONS = 10;
const INITIAL_TASKS = 5;
const TASKS_CENTER_POLL_INTERVAL = 60 * 60 * 1000; // 1 hour in milliseconds
const useSocket = () => { const useSocket = () => {
const context = useContext(SocketContext); const context = useContext(SocketContext);
@@ -10,4 +12,4 @@ const useSocket = () => {
return context; 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 color
note note
job { job {
scheduled_in
scheduled_completion
alt_transport alt_transport
ro_number ro_number
ownr_ln ownr_ln

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import TasksPageComponent from "./tasks.page.component"; import TasksPageComponent from "./tasks.page.component";
import queryString from "query-string"; 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 { useTranslation } from "react-i18next";
import TasksPageComponent from "./tasks.page.component"; 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 TaskListContainer from "../../components/task-list/task-list.container.jsx";
import { QUERY_ALL_TASKS_PAGINATED, QUERY_MY_TASKS_PAGINATED } from "../../graphql/tasks.queries.js"; import { QUERY_ALL_TASKS_PAGINATED, QUERY_MY_TASKS_PAGINATED } from "../../graphql/tasks.queries.js";
import taskPageTypes from "./taskPageTypes.jsx"; import taskPageTypes from "./taskPageTypes.jsx";
@@ -10,7 +9,7 @@ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser, currentUser: selectCurrentUser,
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({}); const mapDispatchToProps = () => ({});
export default connect(mapStateToProps, mapDispatchToProps)(TasksPageComponent); export default connect(mapStateToProps, mapDispatchToProps)(TasksPageComponent);

View File

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

View File

@@ -426,6 +426,11 @@
"messagingtext": "", "messagingtext": "",
"noteslabel": "", "noteslabel": "",
"notestext": "", "notestext": "",
"notifications": {
"description": "",
"invalid_followers": "",
"placeholder": ""
},
"partslocation": "", "partslocation": "",
"phone": "", "phone": "",
"prodtargethrs": "", "prodtargethrs": "",
@@ -648,15 +653,9 @@
"use_paint_scale_data": "", "use_paint_scale_data": "",
"uselocalmediaserver": "", "uselocalmediaserver": "",
"website": "", "website": "",
"zip_post": "", "zip_post": ""
"notifications": {
"description": "",
"placeholder": "",
"invalid_followers": ""
}
}, },
"labels": { "labels": {
"consent_settings": "",
"2tiername": "", "2tiername": "",
"2tiersetup": "", "2tiersetup": "",
"2tiersource": "", "2tiersource": "",
@@ -667,6 +666,7 @@
"apptcolors": "", "apptcolors": "",
"businessinformation": "", "businessinformation": "",
"checklists": "", "checklists": "",
"consent_settings": "",
"csiq": "", "csiq": "",
"customtemplates": "", "customtemplates": "",
"defaultcostsmapping": "", "defaultcostsmapping": "",
@@ -704,6 +704,9 @@
"messagingpresets": "", "messagingpresets": "",
"notemplatesavailable": "", "notemplatesavailable": "",
"notespresets": "", "notespresets": "",
"notifications": {
"followers": ""
},
"orderstatuses": "", "orderstatuses": "",
"partslocations": "", "partslocations": "",
"partsscan": "", "partsscan": "",
@@ -734,10 +737,7 @@
"ssbuckets": "", "ssbuckets": "",
"systemsettings": "", "systemsettings": "",
"task-presets": "", "task-presets": "",
"workingdays": "", "workingdays": ""
"notifications": {
"followers": ""
}
}, },
"operations": { "operations": {
"contains": "", "contains": "",
@@ -783,6 +783,15 @@
"completed": "" "completed": ""
} }
}, },
"consent": {
"associated_owners": "",
"created_at": "",
"no_owners": "",
"phone_1": "",
"phone_2": "",
"phone_number": "",
"text_body": ""
},
"contracts": { "contracts": {
"actions": { "actions": {
"changerate": "", "changerate": "",
@@ -975,7 +984,10 @@
"addcomponent": "" "addcomponent": ""
}, },
"errors": { "errors": {
"atp": "",
"insco": "",
"refreshrequired": "", "refreshrequired": "",
"status": "",
"updatinglayout": "" "updatinglayout": ""
}, },
"labels": { "labels": {
@@ -998,6 +1010,8 @@
"productiondollars": "", "productiondollars": "",
"productionhours": "", "productionhours": "",
"projectedmonthlysales": "", "projectedmonthlysales": "",
"scheduleddeliverydate": "",
"scheduleddeliverytoday": "",
"scheduledindate": "", "scheduledindate": "",
"scheduledintoday": "", "scheduledintoday": "",
"scheduledoutdate": "", "scheduledoutdate": "",
@@ -1230,11 +1244,11 @@
"fcm": "", "fcm": "",
"notfound": "", "notfound": "",
"sizelimit": "", "sizelimit": "",
"submit-for-testing": "",
"sub_status": { "sub_status": {
"expired": "", "expired": "",
"trial-expired": "" "trial-expired": ""
} },
"submit-for-testing": ""
}, },
"itemtypes": { "itemtypes": {
"contract": "", "contract": "",
@@ -1646,8 +1660,6 @@
"voiding": "" "voiding": ""
}, },
"fields": { "fields": {
"estimate_sent_approval": "",
"estimate_approved": "",
"active_tasks": "", "active_tasks": "",
"actual_completion": "Realización real", "actual_completion": "Realización real",
"actual_delivery": "Entrega real", "actual_delivery": "Entrega real",
@@ -1778,6 +1790,8 @@
"est_ct_ln": "Apellido del tasador", "est_ct_ln": "Apellido del tasador",
"est_ea": "Correo electrónico del tasador", "est_ea": "Correo electrónico del tasador",
"est_ph1": "Número de teléfono del tasador", "est_ph1": "Número de teléfono del tasador",
"estimate_approved": "",
"estimate_sent_approval": "",
"federal_tax_payable": "Impuesto federal por pagar", "federal_tax_payable": "Impuesto federal por pagar",
"federal_tax_rate": "", "federal_tax_rate": "",
"flat_rate_ats": "", "flat_rate_ats": "",
@@ -1961,8 +1975,6 @@
"scheddates": "" "scheddates": ""
}, },
"labels": { "labels": {
"sent": "",
"approved": "",
"accountsreceivable": "", "accountsreceivable": "",
"act_price_ppc": "", "act_price_ppc": "",
"actual_completion_inferred": "", "actual_completion_inferred": "",
@@ -1977,6 +1989,7 @@
"alreadyaddedtoscoreboard": "", "alreadyaddedtoscoreboard": "",
"alreadyclosed": "", "alreadyclosed": "",
"appointmentconfirmation": "¿Enviar confirmación al cliente?", "appointmentconfirmation": "¿Enviar confirmación al cliente?",
"approved": "",
"associationwarning": "", "associationwarning": "",
"audit": "", "audit": "",
"available": "", "available": "",
@@ -2167,6 +2180,7 @@
"sales": "", "sales": "",
"savebeforeconversion": "", "savebeforeconversion": "",
"scheduledinchange": "", "scheduledinchange": "",
"sent": "",
"specialcoveragepolicy": "", "specialcoveragepolicy": "",
"state_tax_amt": "", "state_tax_amt": "",
"subletsnotcompleted": "", "subletsnotcompleted": "",
@@ -2383,15 +2397,16 @@
}, },
"errors": { "errors": {
"invalidphone": "", "invalidphone": "",
"no_consent": "",
"noattachedjobs": "", "noattachedjobs": "",
"updatinglabel": "", "updatinglabel": ""
"no_consent": ""
}, },
"labels": { "labels": {
"addlabel": "", "addlabel": "",
"archive": "", "archive": "",
"maxtenimages": "", "maxtenimages": "",
"messaging": "Mensajería", "messaging": "Mensajería",
"no_consent": "",
"noallowtxt": "", "noallowtxt": "",
"nojobs": "", "nojobs": "",
"nopush": "", "nopush": "",
@@ -2401,8 +2416,7 @@
"selectmedia": "", "selectmedia": "",
"sentby": "", "sentby": "",
"typeamessage": "Enviar un mensaje...", "typeamessage": "Enviar un mensaje...",
"unarchive": "", "unarchive": ""
"no_consent": ""
}, },
"render": { "render": {
"conversation_list": "" "conversation_list": ""
@@ -2422,6 +2436,7 @@
"fields": { "fields": {
"createdby": "Creado por", "createdby": "Creado por",
"critical": "Crítico", "critical": "Crítico",
"pinned": "",
"private": "Privado", "private": "Privado",
"text": "Contenido", "text": "Contenido",
"type": "", "type": "",
@@ -2440,6 +2455,7 @@
"addtorelatedro": "", "addtorelatedro": "",
"newnoteplaceholder": "Agrega una nota...", "newnoteplaceholder": "Agrega una nota...",
"notetoadd": "", "notetoadd": "",
"pinned_note": "",
"systemnotes": "", "systemnotes": "",
"usernotes": "" "usernotes": ""
}, },
@@ -2462,13 +2478,15 @@
"fcm": "" "fcm": ""
}, },
"labels": { "labels": {
"auto-add-on": "",
"auto-add-off": "",
"auto-add-success": "",
"auto-add-failure": "",
"auto-add-description": "",
"add-watchers": "", "add-watchers": "",
"add-watchers-team": "", "add-watchers-team": "",
"auto-add": "",
"auto-add-description": "",
"auto-add-failure": "",
"auto-add-off": "",
"auto-add-on": "",
"auto-add-success": "",
"employee-notification": "",
"employee-search": "", "employee-search": "",
"mark-all-read": "", "mark-all-read": "",
"new-notification-title": "", "new-notification-title": "",
@@ -2485,8 +2503,7 @@
"teams-search": "", "teams-search": "",
"unwatch": "", "unwatch": "",
"watch": "", "watch": "",
"watching-issue": "", "watching-issue": ""
"employee-notification": ""
}, },
"scenarios": { "scenarios": {
"alternate-transport-changed": "", "alternate-transport-changed": "",
@@ -3296,6 +3313,9 @@
"updated": "" "updated": ""
} }
}, },
"settings": {
"title": ""
},
"tasks": { "tasks": {
"actions": { "actions": {
"edit": "", "edit": "",
@@ -3324,6 +3344,9 @@
"tomorrow": "", "tomorrow": "",
"two_weeks": "" "two_weeks": ""
}, },
"errors": {
"load_failure": ""
},
"failures": { "failures": {
"completed": "", "completed": "",
"created": "", "created": "",
@@ -3358,6 +3381,16 @@
"remind_at": "", "remind_at": "",
"title": "" "title": ""
}, },
"labels": {
"due_today": "",
"go_to_job": "",
"my_tasks_center": "",
"no_due_date": "",
"no_tasks": "",
"overdue": "",
"ro-number": "",
"upcoming": ""
},
"placeholders": { "placeholders": {
"assigned_to": "", "assigned_to": "",
"billid": "", "billid": "",
@@ -3873,18 +3906,6 @@
"validation": { "validation": {
"unique_vendor_name": "" "unique_vendor_name": ""
} }
},
"consent": {
"phone_number": "",
"associated_owners": "",
"created_at": "",
"no_owners": "",
"phone_1": "",
"phone_2": "",
"text_body": ""
},
"settings": {
"title": ""
} }
} }
} }

View File

@@ -426,6 +426,11 @@
"messagingtext": "", "messagingtext": "",
"noteslabel": "", "noteslabel": "",
"notestext": "", "notestext": "",
"notifications": {
"description": "",
"invalid_followers": "",
"placeholder": ""
},
"partslocation": "", "partslocation": "",
"phone": "", "phone": "",
"prodtargethrs": "", "prodtargethrs": "",
@@ -648,15 +653,9 @@
"use_paint_scale_data": "", "use_paint_scale_data": "",
"uselocalmediaserver": "", "uselocalmediaserver": "",
"website": "", "website": "",
"zip_post": "", "zip_post": ""
"notifications": {
"description": "",
"placeholder": "",
"invalid_followers": ""
}
}, },
"labels": { "labels": {
"consent_settings": "",
"2tiername": "", "2tiername": "",
"2tiersetup": "", "2tiersetup": "",
"2tiersource": "", "2tiersource": "",
@@ -667,6 +666,7 @@
"apptcolors": "", "apptcolors": "",
"businessinformation": "", "businessinformation": "",
"checklists": "", "checklists": "",
"consent_settings": "",
"csiq": "", "csiq": "",
"customtemplates": "", "customtemplates": "",
"defaultcostsmapping": "", "defaultcostsmapping": "",
@@ -704,6 +704,9 @@
"messagingpresets": "", "messagingpresets": "",
"notemplatesavailable": "", "notemplatesavailable": "",
"notespresets": "", "notespresets": "",
"notifications": {
"followers": ""
},
"orderstatuses": "", "orderstatuses": "",
"partslocations": "", "partslocations": "",
"partsscan": "", "partsscan": "",
@@ -734,10 +737,7 @@
"ssbuckets": "", "ssbuckets": "",
"systemsettings": "", "systemsettings": "",
"task-presets": "", "task-presets": "",
"workingdays": "", "workingdays": ""
"notifications": {
"followers": ""
}
}, },
"operations": { "operations": {
"contains": "", "contains": "",
@@ -783,6 +783,15 @@
"completed": "" "completed": ""
} }
}, },
"consent": {
"associated_owners": "Associated Owners",
"created_at": "Opt-Out Date",
"no_owners": "No Associated Owners",
"phone_1": "Phone 1",
"phone_2": "Phone 2",
"phone_number": "Phone Number",
"text_body": ""
},
"contracts": { "contracts": {
"actions": { "actions": {
"changerate": "", "changerate": "",
@@ -975,7 +984,10 @@
"addcomponent": "" "addcomponent": ""
}, },
"errors": { "errors": {
"atp": "",
"insco": "",
"refreshrequired": "", "refreshrequired": "",
"status": "",
"updatinglayout": "" "updatinglayout": ""
}, },
"labels": { "labels": {
@@ -998,6 +1010,8 @@
"productiondollars": "", "productiondollars": "",
"productionhours": "", "productionhours": "",
"projectedmonthlysales": "", "projectedmonthlysales": "",
"scheduleddeliverydate": "",
"scheduleddeliverytoday": "",
"scheduledindate": "", "scheduledindate": "",
"scheduledintoday": "", "scheduledintoday": "",
"scheduledoutdate": "", "scheduledoutdate": "",
@@ -1230,11 +1244,11 @@
"fcm": "", "fcm": "",
"notfound": "", "notfound": "",
"sizelimit": "", "sizelimit": "",
"submit-for-testing": "",
"sub_status": { "sub_status": {
"expired": "", "expired": "",
"trial-expired": "" "trial-expired": ""
} },
"submit-for-testing": ""
}, },
"itemtypes": { "itemtypes": {
"contract": "", "contract": "",
@@ -1646,8 +1660,6 @@
"voiding": "" "voiding": ""
}, },
"fields": { "fields": {
"estimate_sent_approval": "",
"estimate_approved": "",
"active_tasks": "", "active_tasks": "",
"actual_completion": "Achèvement réel", "actual_completion": "Achèvement réel",
"actual_delivery": "Livraison réelle", "actual_delivery": "Livraison réelle",
@@ -1778,6 +1790,8 @@
"est_ct_ln": "Nom de l'évaluateur", "est_ct_ln": "Nom de l'évaluateur",
"est_ea": "Courriel de l'évaluateur", "est_ea": "Courriel de l'évaluateur",
"est_ph1": "Numéro de téléphone de l'évaluateur", "est_ph1": "Numéro de téléphone de l'évaluateur",
"estimate_approved": "",
"estimate_sent_approval": "",
"federal_tax_payable": "Impôt fédéral à payer", "federal_tax_payable": "Impôt fédéral à payer",
"federal_tax_rate": "", "federal_tax_rate": "",
"flat_rate_ats": "", "flat_rate_ats": "",
@@ -1961,8 +1975,6 @@
"scheddates": "" "scheddates": ""
}, },
"labels": { "labels": {
"sent": "",
"approved": "",
"accountsreceivable": "", "accountsreceivable": "",
"act_price_ppc": "", "act_price_ppc": "",
"actual_completion_inferred": "", "actual_completion_inferred": "",
@@ -1977,6 +1989,7 @@
"alreadyaddedtoscoreboard": "", "alreadyaddedtoscoreboard": "",
"alreadyclosed": "", "alreadyclosed": "",
"appointmentconfirmation": "Envoyer une confirmation au client?", "appointmentconfirmation": "Envoyer une confirmation au client?",
"approved": "",
"associationwarning": "", "associationwarning": "",
"audit": "", "audit": "",
"available": "", "available": "",
@@ -2167,6 +2180,7 @@
"sales": "", "sales": "",
"savebeforeconversion": "", "savebeforeconversion": "",
"scheduledinchange": "", "scheduledinchange": "",
"sent": "",
"specialcoveragepolicy": "", "specialcoveragepolicy": "",
"state_tax_amt": "", "state_tax_amt": "",
"subletsnotcompleted": "", "subletsnotcompleted": "",
@@ -2383,15 +2397,16 @@
}, },
"errors": { "errors": {
"invalidphone": "", "invalidphone": "",
"no_consent": "",
"noattachedjobs": "", "noattachedjobs": "",
"updatinglabel": "", "updatinglabel": ""
"no_consent": ""
}, },
"labels": { "labels": {
"addlabel": "", "addlabel": "",
"archive": "", "archive": "",
"maxtenimages": "", "maxtenimages": "",
"messaging": "Messagerie", "messaging": "Messagerie",
"no_consent": "",
"noallowtxt": "", "noallowtxt": "",
"nojobs": "", "nojobs": "",
"nopush": "", "nopush": "",
@@ -2401,8 +2416,7 @@
"selectmedia": "", "selectmedia": "",
"sentby": "", "sentby": "",
"typeamessage": "Envoyer un message...", "typeamessage": "Envoyer un message...",
"unarchive": "", "unarchive": ""
"no_consent": ""
}, },
"render": { "render": {
"conversation_list": "" "conversation_list": ""
@@ -2422,6 +2436,7 @@
"fields": { "fields": {
"createdby": "Créé par", "createdby": "Créé par",
"critical": "Critique", "critical": "Critique",
"pinned": "",
"private": "privé", "private": "privé",
"text": "Contenu", "text": "Contenu",
"type": "", "type": "",
@@ -2440,6 +2455,7 @@
"addtorelatedro": "", "addtorelatedro": "",
"newnoteplaceholder": "Ajouter une note...", "newnoteplaceholder": "Ajouter une note...",
"notetoadd": "", "notetoadd": "",
"pinned_note": "",
"systemnotes": "", "systemnotes": "",
"usernotes": "" "usernotes": ""
}, },
@@ -2462,13 +2478,15 @@
"fcm": "" "fcm": ""
}, },
"labels": { "labels": {
"auto-add-on": "",
"auto-add-off": "",
"auto-add-success": "",
"auto-add-failure": "",
"auto-add-description": "",
"add-watchers": "", "add-watchers": "",
"add-watchers-team": "", "add-watchers-team": "",
"auto-add": "",
"auto-add-description": "",
"auto-add-failure": "",
"auto-add-off": "",
"auto-add-on": "",
"auto-add-success": "",
"employee-notification": "",
"employee-search": "", "employee-search": "",
"mark-all-read": "", "mark-all-read": "",
"new-notification-title": "", "new-notification-title": "",
@@ -2485,8 +2503,7 @@
"teams-search": "", "teams-search": "",
"unwatch": "", "unwatch": "",
"watch": "", "watch": "",
"watching-issue": "", "watching-issue": ""
"employee-notification": ""
}, },
"scenarios": { "scenarios": {
"alternate-transport-changed": "", "alternate-transport-changed": "",
@@ -3296,6 +3313,9 @@
"updated": "" "updated": ""
} }
}, },
"settings": {
"title": ""
},
"tasks": { "tasks": {
"actions": { "actions": {
"edit": "", "edit": "",
@@ -3324,6 +3344,9 @@
"tomorrow": "", "tomorrow": "",
"two_weeks": "" "two_weeks": ""
}, },
"errors": {
"load_failure": ""
},
"failures": { "failures": {
"completed": "", "completed": "",
"created": "", "created": "",
@@ -3358,6 +3381,16 @@
"remind_at": "", "remind_at": "",
"title": "" "title": ""
}, },
"labels": {
"due_today": "",
"go_to_job": "",
"my_tasks_center": "",
"no_due_date": "",
"no_tasks": "",
"overdue": "",
"ro-number": "",
"upcoming": ""
},
"placeholders": { "placeholders": {
"assigned_to": "", "assigned_to": "",
"billid": "", "billid": "",
@@ -3873,18 +3906,6 @@
"validation": { "validation": {
"unique_vendor_name": "" "unique_vendor_name": ""
} }
},
"consent": {
"phone_number": "Phone Number",
"associated_owners": "Associated Owners",
"created_at": "Opt-Out Date",
"no_owners": "No Associated Owners",
"phone_1": "Phone 1",
"phone_2": "Phone 2",
"text_body": ""
},
"settings": {
"title": ""
} }
} }
} }

View File

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

View File

@@ -4909,6 +4909,7 @@
- critical - critical
- id - id
- jobid - jobid
- pinned
- private - private
- text - text
- type - type
@@ -4923,6 +4924,7 @@
- critical - critical
- id - id
- jobid - jobid
- pinned
- private - private
- text - text
- type - type
@@ -4947,6 +4949,7 @@
- critical - critical
- id - id
- jobid - jobid
- pinned
- private - private
- text - text
- type - type
@@ -6344,11 +6347,13 @@
- joblineid - joblineid
- assigned_to - assigned_to
- due_date - due_date
- deleted
- partsorderid - partsorderid
- completed - completed
- description - description
- billid - billid
- title - title
- jobid
- priority - priority
retry_conf: retry_conf:
interval_sec: 10 interval_sec: 10

View File

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

View File

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

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

View File

@@ -81,7 +81,7 @@ const jobLifecycle = async (req, res) => {
const finalTotal = Object.values(flatGroupedAllDurations).reduce((total, statusArr) => { const finalTotal = Object.values(flatGroupedAllDurations).reduce((total, statusArr) => {
return total + statusArr.reduce((acc, curr) => acc + curr.value, 0); return total + statusArr.reduce((acc, curr) => acc + curr.value, 0);
}, 0); }, 0);
Object.keys(flatGroupedAllDurations).forEach((status) => { Object.keys(flatGroupedAllDurations).forEach((status) => {
const value = flatGroupedAllDurations[status].reduce((acc, curr) => acc + curr.value, 0); const value = flatGroupedAllDurations[status].reduce((acc, curr) => acc + curr.value, 0);
const humanReadable = durationToHumanReadable(moment.duration(value)); const humanReadable = durationToHumanReadable(moment.duration(value));

View File

@@ -50,7 +50,12 @@ const autoAddWatchers = async (req) => {
try { try {
// Fetch bodyshop data from Redis // Fetch bodyshop data from Redis
const bodyshopData = await getBodyshopFromRedis(shopId); 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 // Execute queries in parallel
const [notificationData, existingWatchersData] = await Promise.all([ const [notificationData, existingWatchersData] = await Promise.all([

View File

@@ -145,15 +145,70 @@ const handleNotesChange = async (req, res) =>
const handlePaymentsChange = async (req, res) => const handlePaymentsChange = async (req, res) =>
processNotificationEvent(req, res, "req.body.event.new.jobid", "Payments Changed Notification Event Handled."); 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. * Handle tasks change notifications.
* Note: this also handles task center notifications.
* *
* @param {Object} req - Express request object. * @param {Object} req - Express request object.
* @param {Object} res - Express response object. * @param {Object} res - Express response object.
* @returns {Promise<Object>} JSON response with a success message. * @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."); processNotificationEvent(req, res, "req.body.event.new.jobid", "Tasks Notifications Event Handled.");
handleTaskSocketEmit(req);
};
/** /**
* Handle time tickets change notifications. * Handle time tickets change notifications.

View File

@@ -26,6 +26,7 @@ const redisSocketEvents = ({
try { try {
const user = await admin.auth().verifyIdToken(token); const user = await admin.auth().verifyIdToken(token);
socket.user = user; socket.user = user;
socket.bodyshopId = bodyshopId;
await addUserSocketMapping(user.email, socket.id, bodyshopId); await addUserSocketMapping(user.email, socket.id, bodyshopId);
next(); next();
} catch (error) { } catch (error) {
@@ -55,12 +56,8 @@ const redisSocketEvents = ({
return; return;
} }
socket.user = user; socket.user = user;
socket.bodyshopId = bodyshopId;
await refreshUserSocketTTL(user.email, 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 }); socket.emit("token-updated", { success: true });
} catch (error) { } catch (error) {
if (error.code === "auth/id-token-expired") { if (error.code === "auth/id-token-expired") {
@@ -82,7 +79,6 @@ const redisSocketEvents = ({
try { try {
const room = getBodyshopRoom(bodyshopUUID); const room = getBodyshopRoom(bodyshopUUID);
socket.join(room); socket.join(room);
// createLogEvent(socket, "debug", `Client joined bodyshop room: ${room}`);
} catch (error) { } catch (error) {
createLogEvent(socket, "error", `Error joining room: ${error}`); createLogEvent(socket, "error", `Error joining room: ${error}`);
} }
@@ -92,7 +88,6 @@ const redisSocketEvents = ({
try { try {
const room = getBodyshopRoom(bodyshopUUID); const room = getBodyshopRoom(bodyshopUUID);
socket.leave(room); socket.leave(room);
createLogEvent(socket, "debug", `Client left bodyshop room: ${room}`);
} catch (error) { } catch (error) {
createLogEvent(socket, "error", `Error joining room: ${error}`); createLogEvent(socket, "error", `Error joining room: ${error}`);
} }
@@ -102,8 +97,6 @@ const redisSocketEvents = ({
try { try {
const room = getBodyshopRoom(bodyshopUUID); const room = getBodyshopRoom(bodyshopUUID);
io.to(room).emit("bodyshop-message", message); 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) { } catch (error) {
createLogEvent(socket, "error", `Error getting room: ${error}`); createLogEvent(socket, "error", `Error getting room: ${error}`);
} }
@@ -200,11 +193,6 @@ const redisSocketEvents = ({
io.to(socketId).emit("sync-notification-read", { notificationId, timestamp }); io.to(socketId).emit("sync-notification-read", { notificationId, timestamp });
} }
}); });
createLogEvent(
socket,
"debug",
`Synced notification ${notificationId} read for ${userEmail} in bodyshop ${bodyshopId}`
);
} }
} catch (error) { } catch (error) {
createLogEvent(socket, "error", `Error syncing notification read: ${error.message}`); createLogEvent(socket, "error", `Error syncing notification read: ${error.message}`);
@@ -223,7 +211,6 @@ const redisSocketEvents = ({
io.to(socketId).emit("sync-all-notifications-read", { timestamp }); io.to(socketId).emit("sync-all-notifications-read", { timestamp });
} }
}); });
createLogEvent(socket, "debug", `Synced all notifications read for ${email} in bodyshop ${bodyshopId}`);
} }
} catch (error) { } catch (error) {
createLogEvent(socket, "error", `Error syncing all notifications read: ${error.message}`); 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 // Call Handlers
registerRoomAndBroadcastEvents(socket); registerRoomAndBroadcastEvents(socket);
registerUpdateEvents(socket); registerUpdateEvents(socket);
registerMessagingEvents(socket); registerMessagingEvents(socket);
registerDisconnectEvents(socket); registerDisconnectEvents(socket);
registerSyncEvents(socket); registerSyncEvents(socket);
registerTaskEvents(socket);
}; };
// Associate Middleware and Handlers // Associate Middleware and Handlers