Compare commits

...

117 Commits

Author SHA1 Message Date
Patrick Fic
633d5668f0 IO-3279 Set usage report to 1 year. 2025-06-23 15:08:36 -07:00
Dave Richer
00cc47553b Merged in release/2025-06-13 (pull request #2373)
Release/2025 06 13 into master-AIO - IO-3222 IO-3256 IO-3254
2025-06-19 18:26:08 +00:00
Dave Richer
3c360130a3 Merged in feature/IO-3258-Shop-User-Vendor-Creation (pull request #2371)
Feature/IO-3258 Shop User Vendor Creation
2025-06-09 22:44:01 +00:00
Dave Richer
13e4143eeb feature/IO-3258-Shop-User-Vendor-Creation: Finish 2025-06-09 18:43:15 -04:00
Dave Richer
68c7b184d2 feature/IO-3258-Shop-User-Vendor-Creation: Finish 2025-06-09 18:39:29 -04:00
Dave Richer
9b85d15ff1 feature/IO-3258-Shop-User-Vendor-Creation: bump deps 2025-06-09 11:18:08 -04:00
Dave Richer
e7cf49a2ec Merged in feature/IO-3254-Basic-Lite-Trial-Completion-User-Lockout (pull request #2369)
feature/IO-3254-Basic-Lite-Trial-Completion-User-Lockout - Translations
2025-06-09 15:11:50 +00:00
Dave Richer
04b29b6970 feature/IO-3254-Basic-Lite-Trial-Completion-User-Lockout - Translations 2025-06-09 11:10:54 -04:00
Dave Richer
f5bc79cba7 Merged in feature/IO-3254-Basic-Lite-Trial-Completion-User-Lockout (pull request #2367)
feature/IO-3254-Basic-Lite-Trial-Completion-User-Lockout - Bump deps
2025-06-05 17:25:00 +00:00
Dave Richer
2ae18681cb feature/IO-3254-Basic-Lite-Trial-Completion-User-Lockout - Bump deps 2025-06-05 13:23:52 -04:00
Allan Carr
fda763476a IO-3256 Product Fruits IDs
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-06-04 17:34:23 -07:00
Dave Richer
999cbd80f4 Merged in feature/IO-3222-Vendor-Name-OpenSearch (pull request #2365)
IO-3222 Vendor Name Open Search
2025-06-03 18:11:06 +00:00
Dave Richer
ad2a5fe95b Merged in release/2025-06-02 (pull request #2364)
Release 2025-06-02 into master-AIO - IO-3075, IO-3105, IO-3182, IO-3214, IO-3230, IO-3235, IO-3236, IO-3239, IO-3243, IO-3246, IO-3247, IO-3249, IO-3251
2025-06-02 23:58:37 +00:00
Patrick Fic
d835021069 IO-3092 Resolve imgproxy limit fetch. 2025-06-02 11:03:58 -07:00
Dave Richer
c4b303aee1 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2362)
feature/IO-3182-Phone-Number-Consent - Checkpoint
2025-05-30 18:14:52 +00:00
Dave Richer
e2c5a4cba4 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-30 14:14:05 -04:00
Dave Richer
fd04125ed1 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2360)
feature/IO-3182-Phone-Number-Consent - Checkpoint
2025-05-29 17:51:45 +00:00
Dave Richer
a0566e76ab feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-29 13:49:56 -04:00
Patrick Fic
87e8b2ce27 Merged in feature/IO-3239-integration-logging (pull request #2359)
IO-3239 Additional logging fixes.
2025-05-28 22:22:09 +00:00
Patrick Fic
d52426f5f5 IO-3239 Additional logging fixes. 2025-05-28 15:21:42 -07:00
Allan Carr
5e24404e82 Merged in feature/IO-3251-Quick-Intake-Jobs-at-Change (pull request #2357)
IO-3251 Quick Intake Jobs at Change

Approved-by: Dave Richer
2025-05-28 20:25:37 +00:00
Allan Carr
64a280b111 IO-3251 Quick Intake Jobs at Change
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-05-28 13:26:13 -07:00
Dave Richer
cf393e8f9e Merged in feature/IO-3182-Phone-Number-Consent (pull request #2355)
feature/IO-3182-Phone-Number-Consent - Checkpoint
2025-05-28 18:02:19 +00:00
Dave Richer
909a21023a feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-28 14:01:24 -04:00
Dave Richer
0402156b4d Merged in feature/IO-3182-Phone-Number-Consent (pull request #2353)
Feature/IO-3182 Phone Number Consent
2025-05-28 17:46:42 +00:00
Dave Richer
94bdc6c43f feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-28 13:45:12 -04:00
Dave Richer
9466d36e69 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-28 13:17:21 -04:00
Dave Richer
412efb06e5 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-28 13:07:11 -04:00
Dave Richer
da7e637183 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-28 12:26:48 -04:00
Dave Richer
2e95fa25af feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-28 12:17:43 -04:00
Dave Richer
f6c63bbd74 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2351)
feature/IO-3182-Phone-Number-Consent - Checkpoint
2025-05-27 15:43:03 +00:00
Dave Richer
0a654082c2 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-27 11:41:31 -04:00
Dave Richer
2c20b731d2 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2350)
Feature/IO-3182 Phone Number Consent
2025-05-27 15:38:56 +00:00
Dave Richer
8a22897cdd feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-27 11:35:16 -04:00
Dave Richer
677da61b52 Merge remote-tracking branch 'origin/release/2025-06-02' into feature/IO-3182-Phone-Number-Consent 2025-05-27 11:34:44 -04:00
Dave Richer
6513434bd7 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-27 11:34:16 -04:00
Dave Richer
fe2600029f feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-27 11:03:09 -04:00
Allan Carr
c5b4efedfb Merged in feature/IO-3249-Delete-Job-Watchers (pull request #2348)
IO-3249 Delete Job Watchers

Approved-by: Dave Richer
2025-05-26 20:28:56 +00:00
Allan Carr
310321d0ab IO-3249 Delete Job Watchers
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-05-26 12:49:58 -07:00
Allan Carr
7e884c42ea Merged in feature/IO-3214-Extend-New-Fields-to-Audit-Log (pull request #2346)
IO-3214 Extend New Fields to Audit Log

Approved-by: Dave Richer
2025-05-26 19:10:16 +00:00
Allan Carr
e279bf41a4 IO-3214 Extend New Fields to Audit Log
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-05-26 12:11:26 -07:00
Dave Richer
4a060ab51c Merged in feature/IO-3182-Phone-Number-Consent (pull request #2345)
Feature/IO-3182 Phone Number Consent
2025-05-26 19:08:47 +00:00
Dave Richer
62c1c77a18 feature/IO-3182-Phone-Number-Consent - Finish core functionality 2025-05-26 15:07:57 -04:00
Dave Richer
db19ecb28c feature/IO-3182-Phone-Number-Consent - Front/Back Start/stop logic complete 2025-05-26 14:49:02 -04:00
Dave Richer
51748ce28d feature/IO-3182-Phone-Number-Consent - Front/Back Start/stop logic complete 2025-05-26 14:44:27 -04:00
Allan Carr
4bbfd8a9da Merged in feature/IO-3247-Quick-Deliver-Require-Delivery (pull request #2343)
IO-3247 Quick Deliver Require Delivery

Approved-by: Dave Richer
2025-05-26 17:37:36 +00:00
Allan Carr
d4d2db2cac IO-3247 Quick Deliver Require Delivery
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-05-26 10:29:03 -07:00
Dave Richer
23483144e1 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2341)
feature/IO-3182-Phone-Number-Consent - Up Deps
2025-05-26 17:09:48 +00:00
Dave Richer
67d5dcb062 feature/IO-3182-Phone-Number-Consent - Up Deps 2025-05-26 13:09:07 -04:00
Allan Carr
901a49e571 Merged in feature/IO-3246-Remote-Assist (pull request #2337)
IO-3246 Remote Assist

Approved-by: Dave Richer
2025-05-24 17:29:38 +00:00
Dave Richer
49ae107fde release/2025-06-02 - add phone 2025-05-24 13:23:38 -04:00
Dave Richer
0135281bcd release/2025-06-02 - test push 2025-05-24 13:15:53 -04:00
Allan Carr
99cf95daf0 IO-3246 Remote Assist
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-05-23 16:45:24 -07:00
Allan Carr
8c1758ae49 Merged in feature/IO-3075-Crisp-Basic-Info (pull request #2335)
IO-3075 Crisp Basic Info

Approved-by: Dave Richer
2025-05-23 18:03:15 +00:00
Allan Carr
2d764921ff IO-3075 Crisp Basic Info
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-05-23 10:43:15 -07:00
Dave Richer
858a11f8b4 Merged in feature/IO-3242-Visual-Production-Board-Vertical-Drag-Bug (pull request #2334)
HOTFIX - feature/IO-3242-Visual-Production-Board-Vertical-Drag-Bug - Fix bug
2025-05-23 15:38:17 +00:00
Dave Richer
4859239f55 Merged in feature/IO-3242-Visual-Production-Board-Vertical-Drag-Bug (pull request #2332)
feature/IO-3242-Visual-Production-Board-Vertical-Drag-Bug - Fix bug
2025-05-23 15:01:44 +00:00
Dave Richer
5c64d7185e feature/IO-3242-Visual-Production-Board-Vertical-Drag-Bug - Fix bug 2025-05-23 11:00:21 -04:00
Patrick Fic
152479bc08 Merged in feature/IO-3239-integration-logging (pull request #2331)
Feature/IO-3239 integration logging
2025-05-22 18:56:05 +00:00
Patrick Fic
2c508cf1a1 IO-3239 QBO Logging and integration log schema changes. 2025-05-22 11:54:17 -07:00
Patrick Fic
16a91c772a Merged in release/2025-06-02 (pull request #2330)
Release/2025 06 02
2025-05-22 16:46:27 +00:00
Dave Richer
5c47088b11 release/2025-06-02 - Lint Updates 2025-05-22 11:37:47 -04:00
Allan Carr
8e5dc4fa71 Merged in feature/IO-3243-Job-Costing-TOW (pull request #2327)
IO-3243 Job Costing TOW

Approved-by: Dave Richer
2025-05-22 15:13:34 +00:00
Allan Carr
39c3729f6d Merged in feature/IO-3230-Customer-List-Excel (pull request #2326)
IO-3230 Customer List Excel

Approved-by: Dave Richer
2025-05-22 15:13:03 +00:00
Allan Carr
e3d854e02b IO-3243 Job Costing TOW
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-05-21 17:53:33 -07:00
Allan Carr
618acf2acf IO-3230 Customer List Excel
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-05-21 16:28:09 -07:00
Dave Richer
2cf2b70293 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2325)
Feature/IO-3182 Phone Number Consent
2025-05-21 19:17:52 +00:00
Dave Richer
0541afceb8 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-21 15:17:11 -04:00
Dave Richer
28ed3f9936 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2324)
DO NOT MERGE JUST USING TO UNDO
2025-05-21 19:03:58 +00:00
Dave Richer
6afa50332b feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-21 15:03:02 -04:00
Dave Richer
8c8c68867d feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-21 14:39:17 -04:00
Dave Richer
8ee52598e8 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-21 14:32:35 -04:00
Allan Carr
c822028174 Merged in feature/IO-3235-FeatureAccess-VisualBoard-Color (pull request #2322)
IO-3235 FeatureAccess on VisualBoard for SmartSchedule Option of Color Cards

Approved-by: Dave Richer
2025-05-21 18:06:04 +00:00
Allan Carr
36b82c6195 Merged in feature/IO-3236-HasFeatureAccess-Date (pull request #2323)
IO-3236 HasFeatureAccess Date

Approved-by: Dave Richer
2025-05-21 18:05:23 +00:00
Allan Carr
079dffce4d IO-3236 HasFeatureAccess Date
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-05-20 17:16:27 -07:00
Allan Carr
831802f5af IO-3235 FeatureAccess on VisualBoard for SmartSchedule Option of Color Cards
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-05-20 15:25:29 -07:00
Dave Richer
7bd5190bf2 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-20 18:19:39 -04:00
Dave Richer
83860152a9 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-20 16:04:36 -04:00
Dave Richer
1e10493615 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2321)
Feature/IO-3182 Phone Number Consent
2025-05-20 17:45:24 +00:00
Dave Richer
9d81c68a4d feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-20 13:14:05 -04:00
Dave Richer
985d066978 feature/IO-3182-Phone-Number-Consent - Finish Database changes 2025-05-20 12:49:32 -04:00
Dave Richer
6ad9e27d1d feature/IO-3182-Phone-Number-Consent - Merge master / bump deps 2025-05-20 12:38:31 -04:00
Dave Richer
19ebdda5b3 Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3182-Phone-Number-Consent 2025-05-20 12:30:15 -04:00
Allan Carr
4602dd1183 Merged in master-AIO (pull request #2320)
IO-3217 OTSL Labor Type
2025-05-19 21:03:00 +00:00
Allan Carr
6005eaee6a Merged in feature/IO-3217-OTSL-Labor-Type (pull request #2319)
IO-3217 OTSL Labor Type
2025-05-19 21:02:01 +00:00
Patrick Fic
6d59e3994f Merged in feature/IO-3105-qbo-version-update (pull request #2318)
feature/IO-3105-qbo-version-update

Approved-by: Patrick Fic
2025-05-19 19:30:34 +00:00
Patrick Fic
f770b2f1b1 IO-3105 Add QBO Minor Version. 2025-05-19 12:28:50 -07:00
Patrick Fic
b014744940 IO-3239 Add integration log statements on QBO. 2025-05-19 11:10:02 -07:00
Dave Richer
714c90c25e Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3182-Phone-Number-Consent 2025-05-15 18:55:23 -04:00
Dave Richer
9a3a971da6 Clear Stage 2025-05-15 18:55:20 -04:00
Dave Richer
96cba0aaab Clear Stage 2025-05-15 18:54:55 -04:00
Patrick Fic
c069600cfd Merged in hotfix/2025-05-15 (pull request #2317)
Hotfix/2025 05 15 IO-3217 IO-3066 IO-3210 IO-2328
2025-05-15 22:47:26 +00:00
Patrick Fic
186cbf2c97 Merge branch 'feature/IO-3066-ems-upload' into hotfix/2025-05-15 2025-05-15 15:47:04 -07:00
Patrick Fic
392988ae11 Io-3066 resolve typo. 2025-05-15 15:46:45 -07:00
Allan Carr
2e33b79eb9 Merged in feature/IO-3210-Podium-Datapump (pull request #2315)
IO-3210 Podium Datapump

Approved-by: Patrick Fic
2025-05-15 22:39:39 +00:00
Allan Carr
d4f718c44c Merged in feature/IO-3210-Podium-Datapump (pull request #2314)
IO-3210 Podium Datapump

Approved-by: Patrick Fic
2025-05-15 22:39:28 +00:00
Patrick Fic
fa99ef7b37 Merge branch 'feature/IO-3066-ems-upload' into hotfix/2025-05-15 2025-05-15 15:36:44 -07:00
Patrick Fic
c4aff1b516 Merge branch 'feature/IO-2328-intellipay-querystring-package' into hotfix/2025-05-15 2025-05-15 15:36:31 -07:00
Patrick Fic
61276bb2d1 IO-2328 change querystring versions. 2025-05-15 15:35:56 -07:00
Allan Carr
8b89e2eb9d IO-3210 Podium Datapump
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-05-15 15:27:59 -07:00
Patrick Fic
9ab41308e7 IO-3066 add EMS upload functionality. 2025-05-15 12:53:41 -07:00
Allan Carr
f76052ec9b Merge branch 'master-AIO' into feature/IO-3210-Podium-Datapump
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-05-15 12:41:09 -07:00
Patrick Fic
b8841e3ded Merged in release/2025-05-09 (pull request #2312)
IO-3190 Add nulll coalesce and check for non-intake events.

Approved-by: Patrick Fic
2025-05-15 00:57:44 +00:00
Patrick Fic
a49b3f6496 IO-3190 Add nulll coalesce and check for non-intake events. 2025-05-14 17:56:15 -07:00
Dave Richer
3e17ec3cf8 Merged in release/2025-05-09 (pull request #2310)
[DO NOT MERGE ] Release/2025-05-09 into master-AIO
2025-05-14 20:10:07 +00:00
Dave Richer
76c0c7c41e release/2025-05-09 - Bump Deps 2025-05-13 10:43:15 -04:00
Dave Richer
025b986f60 Merged in feature/IO-3228-Notifications-1.6-and-Deprecations (pull request #2308)
feature/IO-3228-Notifications-1.6-and-Deprecations
2025-05-09 14:39:59 +00:00
Dave Richer
6e6addd62f feature/IO-3228-Notifications-1.6-and-Deprecations
- See Ticket for full details (Notifications restrictions, AntD deprecations)
2025-05-09 10:38:19 -04:00
Dave Richer
266c3acf34 Merged in feature/IO-3214-Job-Status-Card-Extension (pull request #2306)
feature/IO-3214-Job-Status-Card-Extension - PR Notes/Package Updates
2025-05-08 15:27:53 +00:00
Dave Richer
c4631f50e5 feature/IO-3214-Job-Status-Card-Extension - PR Notes/Package Updates 2025-05-08 11:25:44 -04:00
Dave Richer
ca18291425 Merged in feature/IO-3214-Job-Status-Card-Extension (pull request #2304)
feature/IO-3214-Job-Status-Card-Extension - Complete
2025-05-06 21:10:52 +00:00
Dave Richer
110fad2abc feature/IO-3214-Job-Status-Card-Extension - Complete 2025-05-06 17:08:46 -04:00
Dave Richer
b7456cecd4 release/2025-05-09 - Restore GraphQL Dep 2025-05-06 14:55:18 -04:00
Dave Richer
84db1fe81b Merged in feature/IO-3225-Notifications-1.5 (pull request #2300)
Feature/IO-3225 Notifications 1.5 into Release/2025-05-09
2025-05-06 18:20:20 +00:00
Dave Richer
a9814c1eb1 Merged in release/2025-04-25 (pull request #2287)
Release/2025-04-25 into Master-AIO -  IO-2282, IO-3066, IO-3164, IO-3187, IO-3190, IO-3200, IO-3210, IO-3212, IO-3213, IO-3215, IO-3220, IO-3223
2025-04-26 00:43:22 +00:00
Allan Carr
3fe0e3a33c IO-3222 Vendor Name Open Search
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-25 09:39:20 -07:00
Allan Carr
12c87ed689 IO-3217 OTSL Labor Type
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-23 18:48:35 -07:00
162 changed files with 5749 additions and 2738 deletions

View File

@@ -12791,27 +12791,6 @@
</translation> </translation>
</translations> </translations>
</concept_node> </concept_node>
<concept_node>
<name>allow_text_message</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node> <concept_node>
<name>checklist</name> <name>checklist</name>
<definition_loaded>false</definition_loaded> <definition_loaded>false</definition_loaded>
@@ -42614,27 +42593,6 @@
</translation> </translation>
</translations> </translations>
</concept_node> </concept_node>
<concept_node>
<name>allow_text_message</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node> <concept_node>
<name>name</name> <name>name</name>
<definition_loaded>false</definition_loaded> <definition_loaded>false</definition_loaded>

684
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,21 +12,21 @@
"@apollo/client": "^3.13.6", "@apollo/client": "^3.13.6",
"@emotion/is-prop-valid": "^1.3.1", "@emotion/is-prop-valid": "^1.3.1",
"@fingerprintjs/fingerprintjs": "^4.6.1", "@fingerprintjs/fingerprintjs": "^4.6.1",
"@firebase/analytics": "^0.10.12", "@firebase/analytics": "^0.10.16",
"@firebase/app": "^0.11.4", "@firebase/app": "^0.13.1",
"@firebase/auth": "^1.10.0", "@firebase/auth": "^1.10.6",
"@firebase/firestore": "^4.7.10", "@firebase/firestore": "^4.7.17",
"@firebase/messaging": "^0.12.17", "@firebase/messaging": "^0.12.21",
"@jsreport/browser-client": "^3.1.0", "@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.6.1", "@reduxjs/toolkit": "^2.8.2",
"@sentry/cli": "^2.44.0", "@sentry/cli": "^2.46.0",
"@sentry/react": "^9.15.0", "@sentry/react": "^9.27.0",
"@sentry/vite-plugin": "^3.4.0", "@sentry/vite-plugin": "^3.5.0",
"@splitsoftware/splitio-react": "^2.1.1", "@splitsoftware/splitio-react": "^2.3.1",
"@tanem/react-nprogress": "^5.0.53", "@tanem/react-nprogress": "^5.0.53",
"antd": "^5.24.9", "antd": "^5.25.4",
"apollo-link-logger": "^2.0.1", "apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^4.2.0", "apollo-link-sentry": "^4.3.0",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.8.4", "axios": "^1.8.4",
"classnames": "^2.5.1", "classnames": "^2.5.1",
@@ -41,24 +41,25 @@
"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.6", "libphonenumber-js": "^1.12.9",
"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",
"normalize-url": "^8.0.1", "normalize-url": "^8.0.2",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"phone": "^3.1.59",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"query-string": "^9.1.2", "query-string": "^9.2.0",
"raf-schd": "^4.0.3", "raf-schd": "^4.0.3",
"react": "^18.3.1", "react": "^18.3.1",
"react-big-calendar": "^1.18.0", "react-big-calendar": "^1.19.2",
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-cookie": "^8.0.1", "react-cookie": "^8.0.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-drag-listview": "^2.0.0", "react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.1", "react-grid-gallery": "^1.0.1",
"react-grid-layout": "1.3.4", "react-grid-layout": "1.3.4",
"react-i18next": "^15.4.1", "react-i18next": "^15.5.2",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-image-lightbox": "^5.1.4", "react-image-lightbox": "^5.1.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@@ -69,7 +70,7 @@
"react-resizable": "^3.0.5", "react-resizable": "^3.0.5",
"react-router-dom": "^6.30.0", "react-router-dom": "^6.30.0",
"react-sticky": "^6.0.3", "react-sticky": "^6.0.3",
"react-virtuoso": "^4.12.7", "react-virtuoso": "^4.12.8",
"recharts": "^2.15.2", "recharts": "^2.15.2",
"redux": "^5.0.1", "redux": "^5.0.1",
"redux-actions": "^3.0.3", "redux-actions": "^3.0.3",
@@ -77,9 +78,9 @@
"redux-saga": "^1.3.0", "redux-saga": "^1.3.0",
"redux-state-sync": "^3.1.4", "redux-state-sync": "^3.1.4",
"reselect": "^5.1.1", "reselect": "^5.1.1",
"sass": "^1.86.3", "sass": "^1.89.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"styled-components": "^6.1.17", "styled-components": "^6.1.18",
"subscriptions-transport-ws": "^0.11.0", "subscriptions-transport-ws": "^0.11.0",
"use-memo-one": "^1.1.3", "use-memo-one": "^1.1.3",
"vite-plugin-ejs": "^1.7.0", "vite-plugin-ejs": "^1.7.0",
@@ -130,17 +131,17 @@
"@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.43.0", "@dotenvx/dotenvx": "^1.44.1",
"@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.26.0", "@eslint/js": "^9.28.0",
"@playwright/test": "^1.51.1", "@playwright/test": "^1.51.1",
"@sentry/webpack-plugin": "^3.4.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",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.5.1",
"browserslist": "^4.24.5", "browserslist": "^4.25.0",
"browserslist-to-esbuild": "^2.1.1", "browserslist-to-esbuild": "^2.1.1",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"eslint": "^8.57.1", "eslint": "^8.57.1",
@@ -148,7 +149,7 @@
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0", "globals": "^15.15.0",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"memfs": "^4.17.1", "memfs": "^4.17.2",
"os-browserify": "^0.3.0", "os-browserify": "^0.3.0",
"playwright": "^1.51.1", "playwright": "^1.51.1",
"react-error-overlay": "^6.1.0", "react-error-overlay": "^6.1.0",
@@ -160,7 +161,7 @@
"vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pwa": "^1.0.0", "vite-plugin-pwa": "^1.0.0",
"vite-plugin-style-import": "^2.0.0", "vite-plugin-style-import": "^2.0.0",
"vitest": "^3.1.3", "vitest": "^3.2.3",
"workbox-window": "^7.3.0" "workbox-window": "^7.3.0"
} }
} }

View File

@@ -14,8 +14,21 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setPartsOrderContext: (context) => dispatch(setModalContext({ context: context, modal: "partsOrder" })), setPartsOrderContext: (context) =>
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type })) dispatch(
setModalContext({
context: context,
modal: "partsOrder"
})
),
insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
insertAuditTrail({
jobid,
operation,
type
})
)
}); });
export default connect(mapStateToProps, mapDispatchToProps)(BillDetailEditReturn); export default connect(mapStateToProps, mapDispatchToProps)(BillDetailEditReturn);
@@ -69,7 +82,7 @@ export function BillDetailEditReturn({ setPartsOrderContext, insertAuditTrail, b
<Modal <Modal
open={open} open={open}
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
destroyOnClose destroyOnHidden
title={t("bills.actions.return")} title={t("bills.actions.return")}
onOk={() => form.submit()} onOk={() => form.submit()}
> >

View File

@@ -29,7 +29,7 @@ export default function BillDetailEditcontainer() {
delete search.billid; delete search.billid;
history({ search: queryString.stringify(search) }); history({ search: queryString.stringify(search) });
}} }}
destroyOnClose destroyOnHidden
open={search.billid} open={search.billid}
> >
<BillDetailEditComponent /> <BillDetailEditComponent />

View File

@@ -2,10 +2,11 @@ import { useApolloClient, useMutation } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Checkbox, Form, Modal, Space } from "antd"; import { Button, Checkbox, Form, Modal, Space } from "antd";
import _ from "lodash"; import _ from "lodash";
import React, { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, 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 { INSERT_NEW_BILL } from "../../graphql/bills.queries"; import { INSERT_NEW_BILL } from "../../graphql/bills.queries";
import { UPDATE_INVENTORY_LINES } from "../../graphql/inventory.queries"; import { UPDATE_INVENTORY_LINES } from "../../graphql/inventory.queries";
import { UPDATE_JOB_LINE } from "../../graphql/jobs-lines.queries"; import { UPDATE_JOB_LINE } from "../../graphql/jobs-lines.queries";
@@ -24,7 +25,6 @@ import BillFormContainer from "../bill-form/bill-form.container";
import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility"; import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility";
import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility"; import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility";
import { handleUpload } from "../documents-upload/documents-upload.utility"; import { handleUpload } from "../documents-upload/documents-upload.utility";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
billEnterModal: selectBillEnterModal, billEnterModal: selectBillEnterModal,
@@ -196,7 +196,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
job: { lbr_adjustments: newAdjustments } job: { lbr_adjustments: newAdjustments }
} }
}); });
if (!!jobUpdate.errors) { if (jobUpdate.errors) {
notification["error"]({ notification["error"]({
message: t("jobs.errors.saving", { message: t("jobs.errors.saving", {
message: JSON.stringify(jobUpdate.errors) message: JSON.stringify(jobUpdate.errors)
@@ -213,7 +213,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
variables: { partsLineIds: markPolReceived.map((p) => p.id) }, variables: { partsLineIds: markPolReceived.map((p) => p.id) },
refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID"] refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID"]
}); });
if (!!r2.errors) { if (r2.errors) {
setLoading(false); setLoading(false);
setEnterAgain(false); setEnterAgain(false);
notification["error"]({ notification["error"]({
@@ -224,7 +224,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
} }
} }
if (!!r1.errors) { if (r1.errors) {
setLoading(false); setLoading(false);
setEnterAgain(false); setEnterAgain(false);
notification["error"]({ notification["error"]({
@@ -244,7 +244,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
consumedbybillid: billId consumedbybillid: billId
} }
}); });
if (!!r2.errors) { if (r2.errors) {
setLoading(false); setLoading(false);
setEnterAgain(false); setEnterAgain(false);
notification["error"]({ notification["error"]({
@@ -396,7 +396,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
{t("bills.labels.generatepartslabel")} {t("bills.labels.generatepartslabel")}
</Checkbox> </Checkbox>
<Button onClick={handleCancel}>{t("general.actions.cancel")}</Button> <Button onClick={handleCancel}>{t("general.actions.cancel")}</Button>
<Button loading={loading} onClick={() => form.submit()}> <Button loading={loading} onClick={() => form.submit()} id="save-bill-enter-modal">
{t("general.actions.save")} {t("general.actions.save")}
</Button> </Button>
{billEnterModal.context && billEnterModal.context.id ? null : ( {billEnterModal.context && billEnterModal.context.id ? null : (
@@ -406,13 +406,14 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
onClick={() => { onClick={() => {
setEnterAgain(true); setEnterAgain(true);
}} }}
id="save-and-new-bill-enter-modal"
> >
{t("general.actions.saveandnew")} {t("general.actions.saveandnew")}
</Button> </Button>
)} )}
</Space> </Space>
} }
destroyOnClose destroyOnHidden
> >
<Form <Form
onFinish={handleFinish} onFinish={handleFinish}

View File

@@ -1,6 +1,6 @@
import { EditFilled, SyncOutlined } from "@ant-design/icons"; import { EditFilled, SyncOutlined } from "@ant-design/icons";
import { Button, Card, Checkbox, Input, Space, Table } from "antd"; import { Button, Card, Checkbox, Input, Space, Table } from "antd";
import React, { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FaTasks } from "react-icons/fa"; import { FaTasks } from "react-icons/fa";
import { connect } from "react-redux"; import { connect } from "react-redux";
@@ -209,6 +209,7 @@ export function BillsListTableComponent({
} }
}); });
}} }}
id="reconcile-bills-button"
> >
<LockerWrapperComponent featureName="bills"> {t("jobs.actions.reconcile")}</LockerWrapperComponent> <LockerWrapperComponent featureName="bills"> {t("jobs.actions.reconcile")}</LockerWrapperComponent>
</Button> </Button>

View File

@@ -75,7 +75,7 @@ export function ContractsFindModalContainer({ caBcEtfTableModal, toggleModalVisi
title={t("payments.labels.findermodal")} title={t("payments.labels.findermodal")}
onCancel={() => toggleModalVisible()} onCancel={() => toggleModalVisible()}
onOk={() => toggleModalVisible()} onOk={() => toggleModalVisible()}
destroyOnClose destroyOnHidden
forceRender forceRender
> >
<Form form={form} layout="vertical" autoComplete="no" onFinish={handleFinish}> <Form form={form} layout="vertical" autoComplete="no" onFinish={handleFinish}>

View File

@@ -3,6 +3,7 @@ import { Button, Form, InputNumber, Popover, Space } from "antd";
import React, { useState } from "react"; import React, { 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";
export default function CABCpvrtCalculator({ disabled, form }) { export default function CABCpvrtCalculator({ disabled, form }) {
const [visibility, setVisibility] = useState(false); const [visibility, setVisibility] = useState(false);
@@ -39,7 +40,7 @@ export default function CABCpvrtCalculator({ disabled, form }) {
); );
return ( return (
<Popover destroyTooltipOnHide content={popContent} open={visibility} disabled={disabled}> <Popover destroyOnHidden content={popContent} open={visibility} disabled={disabled}>
<Button disabled={disabled} onClick={() => setVisibility(true)}> <Button disabled={disabled} onClick={() => setVisibility(true)}>
<CalculatorFilled /> <CalculatorFilled />
</Button> </Button>

View File

@@ -40,7 +40,7 @@ function CardPaymentModalContainer({ cardPaymentModal, toggleModalVisible, bodys
</Button> </Button>
]} ]}
width="80%" width="80%"
destroyOnClose destroyOnHidden
> >
<CardPaymentModalComponent /> <CardPaymentModalComponent />
</Modal> </Modal>

View File

@@ -34,16 +34,14 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) {
SubscribeToTopicForFCMNotification(); SubscribeToTopicForFCMNotification();
//Register WS handlers // Register WebSocket handlers
if (socket && socket.connected) { if (socket && socket.connected) {
registerMessagingHandlers({ socket, client }); registerMessagingHandlers({ socket, client });
}
return () => { return () => {
if (socket && socket.connected) {
unregisterMessagingHandlers({ socket }); unregisterMessagingHandlers({ socket });
} };
}; }
}, [bodyshop, socket, t, client]); }, [bodyshop, socket, t, client]);
if (!bodyshop || !bodyshop.messagingservicesid) return <></>; if (!bodyshop || !bodyshop.messagingservicesid) return <></>;

View File

@@ -202,8 +202,6 @@ export const registerMessagingHandlers = ({ socket, client }) => {
text: message.text text: message.text
}; };
// Add cases for other known message types as needed
default: default:
// Log a warning for unhandled message types // Log a warning for unhandled message types
logLocal("handleMessageChanged - Unhandled message type", { type: message.type }); logLocal("handleMessageChanged - Unhandled message type", { type: message.type });
@@ -211,7 +209,7 @@ export const registerMessagingHandlers = ({ socket, client }) => {
} }
} }
return messageRef; // Keep other messages unchanged return messageRef;
}); });
} }
} }
@@ -245,11 +243,8 @@ export const registerMessagingHandlers = ({ socket, client }) => {
}); });
const updatedList = existingList?.conversations const updatedList = existingList?.conversations
? [ ? [newConversation, ...existingList.conversations.filter((conv) => conv.id !== newConversation.id)]
newConversation, : [newConversation]; // Prevent duplicates
...existingList.conversations.filter((conv) => conv.id !== newConversation.id) // Prevent duplicates
]
: [newConversation];
client.cache.writeQuery({ client.cache.writeQuery({
query: CONVERSATION_LIST_QUERY, query: CONVERSATION_LIST_QUERY,
@@ -403,6 +398,7 @@ export const registerMessagingHandlers = ({ socket, client }) => {
} }
break; break;
default: default:
logLocal("handleConversationChanged - Unhandled type", { type }); logLocal("handleConversationChanged - Unhandled type", { type });
client.cache.modify({ client.cache.modify({
@@ -419,10 +415,95 @@ export const registerMessagingHandlers = ({ socket, client }) => {
} }
}; };
// Existing handler for phone number opt-out
const handlePhoneNumberOptedOut = async (data) => {
const { bodyshopid, phone_number } = data;
logLocal("handlePhoneNumberOptedOut - Start", data);
try {
client.cache.modify({
id: "ROOT_QUERY",
fields: {
phone_number_opt_out(existing = [], { readField }) {
const phoneNumberExists = existing.some(
(ref) => readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid
);
if (phoneNumberExists) {
logLocal("handlePhoneNumberOptedOut - Phone number already in cache", { phone_number, bodyshopid });
return existing;
}
const newOptOut = {
__typename: "phone_number_opt_out",
id: `temporary-${phone_number}-${Date.now()}`,
bodyshopid,
phone_number,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
return [...existing, newOptOut];
}
},
broadcast: true
});
client.cache.evict({
id: "ROOT_QUERY",
fieldName: "phone_number_opt_out",
args: { bodyshopid, search: phone_number }
});
client.cache.gc();
logLocal("handlePhoneNumberOptedOut - Cache updated successfully", data);
} catch (error) {
console.error("Error updating cache for phone number opt-out:", error);
logLocal("handlePhoneNumberOptedOut - Error", { error: error.message });
}
};
// New handler for phone number opt-in
const handlePhoneNumberOptedIn = async (data) => {
const { bodyshopid, phone_number } = data;
logLocal("handlePhoneNumberOptedIn - Start", data);
try {
// Update the Apollo cache for GET_PHONE_NUMBER_OPT_OUTS by removing the phone number
client.cache.modify({
id: "ROOT_QUERY",
fields: {
phone_number_opt_out(existing = [], { readField }) {
// Filter out the phone number from the opt-out list
return existing.filter(
(ref) => !(readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid)
);
}
},
broadcast: true // Trigger UI updates
});
// Evict the cache entry to force a refetch on next query
client.cache.evict({
id: "ROOT_QUERY",
fieldName: "phone_number_opt_out",
args: { bodyshopid, search: phone_number }
});
client.cache.gc();
logLocal("handlePhoneNumberOptedIn - Cache updated successfully", data);
} catch (error) {
console.error("Error updating cache for phone number opt-in:", error);
logLocal("handlePhoneNumberOptedIn - Error", { error: error.message });
}
};
socket.on("new-message-summary", handleNewMessageSummary); socket.on("new-message-summary", handleNewMessageSummary);
socket.on("new-message-detailed", handleNewMessageDetailed); socket.on("new-message-detailed", handleNewMessageDetailed);
socket.on("message-changed", handleMessageChanged); socket.on("message-changed", handleMessageChanged);
socket.on("conversation-changed", handleConversationChanged); socket.on("conversation-changed", handleConversationChanged);
socket.on("phone-number-opted-out", handlePhoneNumberOptedOut);
socket.on("phone-number-opted-in", handlePhoneNumberOptedIn);
}; };
export const unregisterMessagingHandlers = ({ socket }) => { export const unregisterMessagingHandlers = ({ socket }) => {
@@ -431,4 +512,6 @@ export const unregisterMessagingHandlers = ({ socket }) => {
socket.off("new-message-detailed"); socket.off("new-message-detailed");
socket.off("message-changed"); socket.off("message-changed");
socket.off("conversation-changed"); socket.off("conversation-changed");
socket.off("phone-number-opted-out");
socket.off("phone-number-opted-in");
}; };

View File

@@ -1,5 +1,5 @@
import { Badge, Card, List, Space, Tag } from "antd"; import { Badge, Card, List, Space, Tag, Tooltip } from "antd";
import React, { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Virtuoso } from "react-virtuoso"; import { Virtuoso } from "react-virtuoso";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -9,36 +9,62 @@ import { TimeAgoFormatter } from "../../utils/DateFormatter";
import PhoneFormatter from "../../utils/PhoneFormatter"; import PhoneFormatter from "../../utils/PhoneFormatter";
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component"; import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import _ from "lodash"; import _ from "lodash";
import { ExclamationCircleOutlined } from "@ant-design/icons";
import "./chat-conversation-list.styles.scss"; import "./chat-conversation-list.styles.scss";
import { useQuery } from "@apollo/client";
import { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries.js";
import { phone } from "phone";
import { useTranslation } from "react-i18next";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation selectedConversation: selectSelectedConversation,
bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId)) setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId))
}); });
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) { function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) {
// That comma is there for a reason, do not remove it const { t } = useTranslation();
const [, forceUpdate] = useState(false); const [, forceUpdate] = useState(false);
// Re-render every minute const phoneNumbers = conversationList.map((item) => phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, ""));
const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUTS, {
variables: {
bodyshopid: bodyshop.id,
phone_numbers: phoneNumbers
},
skip: !conversationList.length,
fetchPolicy: "cache-and-network"
});
const optOutMap = useMemo(() => {
const map = new Map();
optOutData?.phone_number_opt_out?.forEach((optOut) => {
map.set(optOut.phone_number, true);
});
return map;
}, [optOutData?.phone_number_opt_out]);
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
forceUpdate((prev) => !prev); // Toggle state to trigger re-render forceUpdate((prev) => !prev);
}, 60000); // 1 minute in milliseconds }, 60000);
return () => clearInterval(interval);
return () => clearInterval(interval); // Cleanup on unmount
}, []); }, []);
// Memoize the sorted conversation list const sortedConversationList = useMemo(() => {
const sortedConversationList = React.useMemo(() => {
return _.orderBy(conversationList, ["updated_at"], ["desc"]); return _.orderBy(conversationList, ["updated_at"], ["desc"]);
}, [conversationList]); }, [conversationList]);
const renderConversation = (index) => { const renderConversation = (index, t) => {
const item = sortedConversationList[index]; const item = sortedConversationList[index];
const normalizedPhone = phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, "");
const hasOptOutEntry = optOutMap.has(normalizedPhone);
const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>; const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
const cardContentLeft = const cardContentLeft =
item.job_conversations.length > 0 item.job_conversations.length > 0
@@ -60,7 +86,18 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
</> </>
); );
const cardExtra = <Badge count={item.messages_aggregate.aggregate.count} />; const cardExtra = (
<>
<Badge count={item.messages_aggregate.aggregate.count} />
{hasOptOutEntry && (
<Tooltip title={t("consent.text_body")}>
<Tag color="red" icon={<ExclamationCircleOutlined />}>
{t("messaging.labels.no_consent")}
</Tag>
</Tooltip>
)}
</>
);
const getCardStyle = () => const getCardStyle = () =>
item.id === selectedConversation item.id === selectedConversation
@@ -73,9 +110,25 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
onClick={() => setSelectedConversation(item.id)} onClick={() => setSelectedConversation(item.id)}
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`} className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
> >
<Card style={getCardStyle()} bordered={false} size="small" extra={cardExtra} title={cardTitle}> <Card style={getCardStyle()} variant={true} size="small" extra={cardExtra} title={cardTitle}>
<div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div> <div
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div> style={{
display: "inline-block",
width: "70%",
textAlign: "left"
}}
>
{cardContentLeft}
</div>
<div
style={{
display: "inline-block",
width: "30%",
textAlign: "right"
}}
>
{cardContentRight}
</div>
</Card> </Card>
</List.Item> </List.Item>
); );
@@ -85,7 +138,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
<div className="chat-list-container"> <div className="chat-list-container">
<Virtuoso <Virtuoso
data={sortedConversationList} data={sortedConversationList}
itemContent={(index) => renderConversation(index)} itemContent={(index) => renderConversation(index, t)}
style={{ height: "100%", width: "100%" }} style={{ height: "100%", width: "100%" }}
/> />
</div> </div>

View File

@@ -24,7 +24,7 @@
/* Add spacing and better alignment for items */ /* Add spacing and better alignment for items */
.chat-list-item { .chat-list-item {
padding: 0.5rem 0; /* Add spacing between list items */ padding: 0.2rem 0; /* Add spacing between list items */
.ant-card { .ant-card {
border-radius: 8px; /* Slight rounding for card edges */ border-radius: 8px; /* Slight rounding for card edges */

View File

@@ -58,6 +58,7 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
userid userid
created_at created_at
read read
is_system
} }
`, `,
data: message data: message

View File

@@ -13,13 +13,14 @@ import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-document
import JobsDocumentImgproxyGalleryExternal from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component"; import JobsDocumentImgproxyGalleryExternal from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component";
import JobDocumentsLocalGalleryExternal from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component"; import JobDocumentsLocalGalleryExternal from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component"; import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import "./chat-media-selector.styles.scss";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) const mapDispatchToProps = (dispatch) => ({});
});
export default connect(mapStateToProps, mapDispatchToProps)(ChatMediaSelector); export default connect(mapStateToProps, mapDispatchToProps)(ChatMediaSelector);
export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, conversation }) { export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, conversation }) {
@@ -37,9 +38,8 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only", nextFetchPolicy: "network-only",
variables: { variables: {
jobId: conversation.job_conversations[0] && conversation.job_conversations[0].jobid jobId: conversation.job_conversations[0]?.jobid
}, },
skip: !open || !conversation.job_conversations || conversation.job_conversations.length === 0 skip: !open || !conversation.job_conversations || conversation.job_conversations.length === 0
}); });
@@ -56,25 +56,25 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
//If Imageproxy is on, rely only on the LMS selector //If Imageproxy is on, rely only on the LMS selector
//If not on, use the old methods. //If not on, use the old methods.
const content = ( const content = (
<div> <div className="media-selector-content">
{loading && <LoadingSpinner />} {loading && <LoadingSpinner />}
{error && <AlertComponent message={error.message} type="error" />} {error && <AlertComponent message={error.message} type="error" />}
{selectedMedia.filter((s) => s.isSelected).length >= 10 ? ( {selectedMedia.filter((s) => s.isSelected).length >= 10 ? (
<div style={{ color: "red" }}>{t("messaging.labels.maxtenimages")}</div> <div className="error-message">{t("messaging.labels.maxtenimages")}</div>
) : null} ) : null}
{Imgproxy.treatment === "on" ? ( {Imgproxy.treatment === "on" ? (
<> <>
{!bodyshop.uselocalmediaserver && ( {!bodyshop.uselocalmediaserver && (
<JobsDocumentImgproxyGalleryExternal <JobsDocumentImgproxyGalleryExternal
jobId={conversation.job_conversations[0].jobid} jobId={conversation.job_conversations[0]?.jobid}
externalMediaState={[selectedMedia, setSelectedMedia]} externalMediaState={[selectedMedia, setSelectedMedia]}
/> />
)} )}
{bodyshop.uselocalmediaserver && open && ( {bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal <JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]} externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid} jobId={conversation.job_conversations[0]?.jobid}
/> />
)} )}
</> </>
@@ -89,7 +89,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
{bodyshop.uselocalmediaserver && open && ( {bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal <JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]} externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid} jobId={conversation.job_conversations[0]?.jobid}
/> />
)} )}
</> </>
@@ -100,12 +100,17 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
return ( return (
<Popover <Popover
content={ content={
conversation.job_conversations.length === 0 ? <div>{t("messaging.errors.noattachedjobs")}</div> : content conversation.job_conversations.length === 0 ? (
<div className="no-jobs-message">{t("messaging.errors.noattachedjobs")}</div>
) : (
content
)
} }
title={t("messaging.labels.selectmedia")} title={t("messaging.labels.selectmedia")}
trigger="click" trigger="click"
open={open} open={open}
onOpenChange={handleVisibleChange} onOpenChange={handleVisibleChange}
overlayClassName="media-selector-popover"
> >
<Badge count={selectedMedia.filter((s) => s.isSelected).length}> <Badge count={selectedMedia.filter((s) => s.isSelected).length}>
<PictureFilled style={{ margin: "0 .5rem" }} /> <PictureFilled style={{ margin: "0 .5rem" }} />

View File

@@ -0,0 +1,52 @@
.media-selector-popover {
.ant-popover-inner-content {
max-width: 640px;
max-height: 480px;
overflow-y: auto;
padding: 8px;
background-color: #fff;
border-radius: 8px;
}
}
.media-selector-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.error-message {
color: red;
font-size: 12px;
text-align: center;
margin-bottom: 8px;
}
.no-jobs-message {
font-size: 14px;
color: #888;
text-align: center;
padding: 8px;
}
/* Style images within gallery components */
.media-selector-content img {
object-fit: cover;
border-radius: 4px;
margin: 4px;
cursor: pointer;
transition: transform 0.2s;
&:hover {
transform: scale(1.05);
}
}
/* Grid layout for gallery components */
.media-selector-content .ant-image, /* Assuming gallery components use Ant Design's Image */
.media-selector-content .gallery-container { /* Fallback for custom gallery classes */
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 4px;
}

View File

@@ -4,13 +4,16 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
.archive-button { .archive-button {
height: 20px; height: 20px;
border-radius: 4px; border-radius: 4px;
} }
.chat-title { .chat-title {
margin-bottom: 5px; margin-bottom: 5px;
} }
.messages { .messages {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -37,11 +40,13 @@
gap: 8px; gap: 8px;
} }
} }
.chat-send-message-button{
.chat-send-message-button {
margin: 0.3rem; margin: 0.3rem;
padding-left: 0.5rem; padding-left: 0.5rem;
} }
.message-icon { .message-icon {
position: absolute; position: absolute;
bottom: 0.1rem; bottom: 0.1rem;
@@ -125,6 +130,37 @@
} }
} }
.system {
align-items: center;
margin: 0.5rem 10%;
.message {
background-color: #f5f5f5;
border-radius: 10px;
padding: 0.5rem 1rem;
text-align: center;
font-style: italic;
color: #555;
width: fit-content;
max-width: 80%;
}
.system-label {
font-size: 0.75rem;
color: #888;
margin-bottom: 0.2rem;
display: block;
}
.system-date {
font-size: 0.75rem;
color: #888;
margin-top: 0.2rem;
text-align: center;
}
}
.virtuoso-container { .virtuoso-container {
flex: 1; flex: 1;
overflow: auto; overflow: auto;

View File

@@ -2,17 +2,29 @@ import Icon from "@ant-design/icons";
import { Tooltip } from "antd"; import { Tooltip } from "antd";
import i18n from "i18next"; import i18n from "i18next";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import { MdDone, MdDoneAll } from "react-icons/md"; import { MdClose, MdDone, MdDoneAll } from "react-icons/md";
import { DateTimeFormatter } from "../../utils/DateFormatter"; import { DateTimeFormatter } from "../../utils/DateFormatter";
export const renderMessage = (messages, index) => { export const renderMessage = (messages, index) => {
const message = messages[index]; const message = messages[index];
const isSystem = message.is_system;
// Determine message class
const messageClass = isSystem ? "system messages" : message.isoutbound ? "mine messages" : "yours messages";
// Tooltip content based on message type
const tooltipTitle = isSystem ? (
i18n.t("consent.text_body")
) : (
<DateTimeFormatter>{message.created_at}</DateTimeFormatter>
);
return ( return (
<div key={index} className={`${message.isoutbound ? "mine messages" : "yours messages"}`}> <div key={index} className={messageClass}>
<div className="message msgmargin"> <div className="message msgmargin">
<Tooltip title={DateTimeFormatter({ children: message.created_at })}> <Tooltip title={tooltipTitle}>
<div> <div>
{isSystem && <span className="system-label">System</span>}
{/* Render images if available */} {/* Render images if available */}
{message.image && message.image_path?.length > 0 && ( {message.image && message.image_path?.length > 0 && (
<div className="message-images"> <div className="message-images">
@@ -26,20 +38,31 @@ export const renderMessage = (messages, index) => {
</div> </div>
)} )}
{/* Render text if available */} {/* Render text if available */}
{message.text && <div>{message.text}</div>} {message.text && <div className="message-text">{message.text}</div>}
{/* Render date for system messages */}
{isSystem && (
<div className="system-date">
<DateTimeFormatter>{message.created_at}</DateTimeFormatter>
</div>
)}
</div> </div>
</Tooltip> </Tooltip>
{/* Message status icons */} {/* Message status icons for non-system messages */}
{message.status && (message.status === "sent" || message.status === "delivered") && ( {!isSystem &&
<div className="message-status"> message.status &&
<Icon component={message.status === "sent" ? MdDone : MdDoneAll} className="message-icon" /> (message.status === "sent" || message.status === "delivered" || message.status === "failed") && (
</div> <div className="message-status">
)} <Icon
component={message.status === "sent" ? MdDone : message.status === "delivered" ? MdDoneAll : MdClose}
className="message-icon"
style={message.status === "failed" ? { color: "#ff0000" } : undefined}
/>
</div>
)}
</div> </div>
{/* Outbound message metadata for non-system messages */}
{/* Outbound message metadata */} {!isSystem && message.isoutbound && (
{message.isoutbound && (
<div style={{ fontSize: 10 }}> <div style={{ fontSize: 10 }}>
{i18n.t("messaging.labels.sentby", { {i18n.t("messaging.labels.sentby", {
by: message.userid, by: message.userid,

View File

@@ -1,6 +1,6 @@
import { LoadingOutlined, SendOutlined } from "@ant-design/icons"; import { ExclamationCircleOutlined, LoadingOutlined, SendOutlined } from "@ant-design/icons";
import { Input, Spin } from "antd"; import { Alert, Input, Space, Spin, Tooltip } from "antd";
import React, { useEffect, useRef, useState } from "react"; import { useEffect, useRef, 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";
@@ -10,6 +10,9 @@ import { selectIsSending, selectMessage } from "../../redux/messaging/messaging.
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import ChatMediaSelector from "../chat-media-selector/chat-media-selector.component"; import ChatMediaSelector from "../chat-media-selector/chat-media-selector.component";
import ChatPresetsComponent from "../chat-presets/chat-presets.component"; import ChatPresetsComponent from "../chat-presets/chat-presets.component";
import { useQuery } from "@apollo/client";
import { phone } from "phone";
import { GET_PHONE_NUMBER_OPT_OUT } from "../../graphql/phone-number-opt-out.queries";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -25,16 +28,24 @@ const mapDispatchToProps = (dispatch) => ({
function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSending, message, setMessage }) { function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSending, message, setMessage }) {
const inputArea = useRef(null); const inputArea = useRef(null);
const [selectedMedia, setSelectedMedia] = useState([]); const [selectedMedia, setSelectedMedia] = useState([]);
const { t } = useTranslation();
const normalizedPhone = phone(conversation.phone_num, "CA").phoneNumber.replace(/^\+1/, "");
const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUT, {
variables: { bodyshopid: bodyshop.id, phone_number: normalizedPhone },
fetchPolicy: "cache-and-network"
});
const isOptedOut = !!optOutData?.phone_number_opt_out?.[0];
useEffect(() => { useEffect(() => {
inputArea.current.focus(); inputArea.current.focus();
}, [isSending, setMessage]); }, [isSending, setMessage]);
const { t } = useTranslation();
const handleEnter = () => { const handleEnter = () => {
const selectedImages = selectedMedia.filter((i) => i.isSelected); const selectedImages = selectedMedia.filter((i) => i.isSelected);
if ((message === "" || !message) && selectedImages.length === 0) return; if ((message === "" || !message) && selectedImages.length === 0) return;
if (isOptedOut) return; // Prevent sending if phone number is opted out
logImEXEvent("messaging_send_message"); logImEXEvent("messaging_send_message");
if (selectedImages.length < 11) { if (selectedImages.length < 11) {
@@ -44,7 +55,8 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
messagingServiceSid: bodyshop.messagingservicesid, messagingServiceSid: bodyshop.messagingservicesid,
conversationid: conversation.id, conversationid: conversation.id,
selectedMedia: selectedImages, selectedMedia: selectedImages,
imexshopid: bodyshop.imexshopid imexshopid: bodyshop.imexshopid,
bodyshopid: bodyshop.id
}; };
sendMessage(newMessage); sendMessage(newMessage);
setSelectedMedia( setSelectedMedia(
@@ -56,47 +68,67 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
}; };
return ( return (
<div className="imex-flex-row" style={{ width: "100%" }}> <Space direction="vertical" style={{ width: "100%" }} size="middle">
<ChatPresetsComponent className="imex-flex-row__margin" /> {isOptedOut && (
<ChatMediaSelector <Tooltip title={t("consent.text_body")}>
conversation={conversation} <Alert
selectedMedia={selectedMedia} showIcon={true}
setSelectedMedia={setSelectedMedia} icon={<ExclamationCircleOutlined />}
/> message={t("messaging.errors.no_consent")}
<span style={{ flex: 1 }}> type="error"
<Input.TextArea
className="imex-flex-row__margin imex-flex-row__grow"
allowClear
autoFocus
ref={inputArea}
autoSize={{ minRows: 1, maxRows: 4 }}
value={message}
disabled={isSending}
placeholder={t("messaging.labels.typeamessage")}
onChange={(e) => setMessage(e.target.value)}
onPressEnter={(event) => {
event.preventDefault();
if (!!!event.shiftKey) handleEnter();
}}
/>
</span>
<SendOutlined
className="chat-send-message-button"
// disabled={message === "" || !message}
onClick={handleEnter}
/>
<Spin
style={{ display: `${isSending ? "" : "none"}` }}
indicator={
<LoadingOutlined
style={{
fontSize: 24
}}
spin
/> />
} </Tooltip>
/> )}
</div> <div className="imex-flex-row" style={{ width: "100%" }}>
{!isOptedOut && (
<>
<ChatPresetsComponent disabled={isSending} className="imex-flex-row__margin" />
<ChatMediaSelector
disabled={isSending}
conversation={conversation}
selectedMedia={selectedMedia}
setSelectedMedia={setSelectedMedia}
/>
</>
)}
<span style={{ flex: 1 }}>
<Input.TextArea
className="imex-flex-row__margin imex-flex-row__grow"
allowClear
autoFocus
ref={inputArea}
autoSize={{ minRows: 1, maxRows: 4 }}
value={message}
disabled={isSending || isOptedOut}
placeholder={t("messaging.labels.typeamessage")}
onChange={(e) => setMessage(e.target.value)}
onPressEnter={(event) => {
event.preventDefault();
if (!event.shiftKey && !isOptedOut) handleEnter();
}}
/>
</span>
{!isOptedOut && (
<SendOutlined
className="chat-send-message-button"
disabled={isSending || message === "" || !message}
onClick={handleEnter}
/>
)}
<Spin
style={{ display: `${isSending ? "" : "none"}` }}
indicator={
<LoadingOutlined
style={{
fontSize: 24
}}
spin
/>
}
/>
</div>
</Space>
); );
} }

View File

@@ -63,7 +63,7 @@ export function ContractsFindModalContainer({
title={t("contracts.labels.findermodal")} title={t("contracts.labels.findermodal")}
onCancel={() => toggleModalVisible()} onCancel={() => toggleModalVisible()}
onOk={() => toggleModalVisible()} onOk={() => toggleModalVisible()}
destroyOnClose destroyOnHidden
forceRender forceRender
> >
<Form form={form} layout="vertical" autoComplete="no" onFinish={handleFinish}> <Form form={form} layout="vertical" autoComplete="no" onFinish={handleFinish}>

View File

@@ -152,7 +152,7 @@ export function EmailOverlayContainer({ emailConfig, modalVisible, toggleEmailOv
}, [modalVisible]); // eslint-disable-line react-hooks/exhaustive-deps }, [modalVisible]); // eslint-disable-line react-hooks/exhaustive-deps
return ( return (
<Modal <Modal
destroyOnClose={true} destroyOnHidden
open={modalVisible} open={modalVisible}
maskClosable={false} maskClosable={false}
width={"80%"} width={"80%"}

View File

@@ -81,8 +81,9 @@ export function HasFeatureAccess({ featureName, bodyshop, bypass, debug = false
} }
return ( return (
bodyshop?.features?.allAccess || bodyshop?.features?.allAccess ||
bodyshop?.features?.[featureName] || (typeof bodyshop?.features?.[featureName] === "boolean"
dayjs(bodyshop?.features[featureName]).isAfter(dayjs()) ? bodyshop?.features?.[featureName]
: dayjs(bodyshop?.features?.[featureName]).isAfter(dayjs()))
); );
} }

View File

@@ -15,6 +15,7 @@ import {
HomeFilled, HomeFilled,
ImportOutlined, ImportOutlined,
LineChartOutlined, LineChartOutlined,
OneToOneOutlined,
PaperClipOutlined, PaperClipOutlined,
PhoneOutlined, PhoneOutlined,
PlusCircleOutlined, PlusCircleOutlined,
@@ -24,6 +25,7 @@ import {
TeamOutlined, TeamOutlined,
ToolFilled, ToolFilled,
UnorderedListOutlined, UnorderedListOutlined,
UsergroupAddOutlined,
UserOutlined UserOutlined
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
@@ -40,6 +42,7 @@ import { RiSurveyLine } from "react-icons/ri";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { 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 { 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";
@@ -47,10 +50,10 @@ 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 InstanceRenderManager from "../../utils/instanceRenderMgr";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapper from "../lock-wrapper/lock-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 { useSocket } from "../../contexts/SocketIO/useSocket.js";
// Redux mappings // Redux mappings
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
@@ -98,6 +101,7 @@ function Header({
const baseTitleRef = useRef(document.title || ""); const baseTitleRef = useRef(document.title || "");
const lastSetTitleRef = useRef(""); const lastSetTitleRef = useRef("");
const userAssociationId = bodyshop?.associations?.[0]?.id; const userAssociationId = bodyshop?.associations?.[0]?.id;
const isEmployee = useIsEmployee(bodyshop, currentUser);
const { const {
data: unreadData, data: unreadData,
@@ -640,17 +644,32 @@ function Header({
label: t("menus.header.help"), label: t("menus.header.help"),
onClick: () => window.open("https://help.imex.online/", "_blank") onClick: () => window.open("https://help.imex.online/", "_blank")
}, },
...(InstanceRenderManager({ imex: true, rome: false }) {
? [ key: "remoteassist",
{ id: "header-remote-assist",
key: "rescue", icon: <OneToOneOutlined />,
id: "header-rescue", label: t("menus.header.remoteassist"),
icon: <CarFilled />, children: [
label: t("menus.header.rescueme"), ...(InstanceRenderManager({ imex: true, rome: false })
onClick: () => window.open("https://imexrescue.com/", "_blank") ? [
} {
] 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", key: "shiftclock",
id: "header-shiftclock", id: "header-shiftclock",
@@ -682,7 +701,7 @@ function Header({
icon: unreadLoading ? ( icon: unreadLoading ? (
<Spin size="small" /> <Spin size="small" />
) : ( ) : (
<Badge offset={[8, 0]} size="small" count={unreadCount}> <Badge offset={[8, 0]} size="small" count={isEmployee ? unreadCount : 0}>
<BellFilled /> <BellFilled />
</Badge> </Badge>
), ),

View File

@@ -98,7 +98,7 @@ export function InventoryUpsertModalContainer({ currentUser, bodyshop, inventory
onCancel={() => { onCancel={() => {
toggleModalVisible(); toggleModalVisible();
}} }}
destroyOnClose destroyOnHidden
> >
<Form form={form} onFinish={handleFinish} layout="vertical"> <Form form={form} onFinish={handleFinish} layout="vertical">
<InventoryUpsertModal form={form} /> <InventoryUpsertModal form={form} />

View File

@@ -66,7 +66,7 @@ export function ScheduleEventComponent({
const [popOverVisible, setPopOverVisible] = useState(false); const [popOverVisible, setPopOverVisible] = useState(false);
const [getJobDetails] = useLazyQuery(GET_JOB_BY_PK_QUICK_INTAKE, { const [getJobDetails] = useLazyQuery(GET_JOB_BY_PK_QUICK_INTAKE, {
variables: { id: event.job.id }, variables: { id: event.job?.id },
onCompleted: (data) => { onCompleted: (data) => {
if (data?.jobs_by_pk) { if (data?.jobs_by_pk) {
const totalHours = const totalHours =
@@ -83,6 +83,7 @@ export function ScheduleEventComponent({
}); });
} }
}, },
fetchPolicy: "network-only" fetchPolicy: "network-only"
}); });
@@ -394,30 +395,33 @@ export function ScheduleEventComponent({
) : ( ) : (
<ScheduleManualEvent event={event} /> <ScheduleManualEvent event={event} />
)} )}
{event.isintake && HasFeatureAccess({ featureName: "checklist", bodyshop }) ? ( {event.job &&
<Link (HasFeatureAccess({ featureName: "checklist", bodyshop }) ? (
to={{ <Link
pathname: `/manage/jobs/${event.job && event.job.id}/intake`, to={{
search: `?appointmentId=${event.id}` pathname: `/manage/jobs/${event.job && event.job.id}/intake`,
}} search: `?appointmentId=${event.id}`
> }}
<Button disabled={event.arrived}>{t("appointments.actions.intake")}</Button> >
</Link> <Button disabled={event.arrived}>{t("appointments.actions.intake")}</Button>
) : ( </Link>
<Popover //open={open} ) : (
content={popMenu} <Popover //open={open}
open={popOverVisible} content={popMenu}
onOpenChange={setPopOverVisible} open={popOverVisible}
onClick={(e) => { onOpenChange={setPopOverVisible}
getJobDetails(); onClick={(e) => {
e.stopPropagation(); if (event.job?.id) {
}} e.stopPropagation();
getPopupContainer={(trigger) => trigger.parentNode} getJobDetails();
trigger="click" }
> }}
<Button disabled={event.arrived}>{t("jobs.actions.intake_quick")}</Button> getPopupContainer={(trigger) => trigger.parentNode}
</Popover> trigger="click"
)} >
<Button disabled={event.arrived}>{t("jobs.actions.intake_quick")}</Button>
</Popover>
))}
</Space> </Space>
</div> </div>
); );

View File

@@ -1,7 +1,7 @@
import { useMutation } from "@apollo/client"; import { useMutation } from "@apollo/client";
import { Button, Card, Form, Input, Switch } from "antd"; import { Button, Card, Form, Input, Switch } 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 { useLocation, useNavigate, useParams } from "react-router-dom"; import { useLocation, useNavigate, useParams } from "react-router-dom";
@@ -9,7 +9,6 @@ import { createStructuredSelector } from "reselect";
import { logImEXEvent } from "../../../../firebase/firebase.utils"; import { logImEXEvent } from "../../../../firebase/firebase.utils";
import { MARK_APPOINTMENT_ARRIVED, MARK_LATEST_APPOINTMENT_ARRIVED } from "../../../../graphql/appointments.queries"; import { MARK_APPOINTMENT_ARRIVED, MARK_LATEST_APPOINTMENT_ARRIVED } from "../../../../graphql/appointments.queries";
import { UPDATE_JOB } from "../../../../graphql/jobs.queries"; import { UPDATE_JOB } from "../../../../graphql/jobs.queries";
import { UPDATE_OWNER } from "../../../../graphql/owners.queries";
import { insertAuditTrail } from "../../../../redux/application/application.actions"; import { insertAuditTrail } from "../../../../redux/application/application.actions";
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";
@@ -32,7 +31,6 @@ export function JobChecklistForm({ insertAuditTrail, formItems, bodyshop, curren
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [markAptArrived] = useMutation(MARK_APPOINTMENT_ARRIVED); const [markAptArrived] = useMutation(MARK_APPOINTMENT_ARRIVED);
const [markLatestAptArrived] = useMutation(MARK_LATEST_APPOINTMENT_ARRIVED); const [markLatestAptArrived] = useMutation(MARK_LATEST_APPOINTMENT_ARRIVED);
const [updateOwner] = useMutation(UPDATE_OWNER);
const notification = useNotification(); const notification = useNotification();
const { jobId } = useParams(); const { jobId } = useParams();
@@ -129,24 +127,6 @@ export function JobChecklistForm({ insertAuditTrail, formItems, bodyshop, curren
} }
} }
if (type === "intake" && job.owner && job.owner.id) {
//Updae Owner Allow to Text
const updateOwnerResult = await updateOwner({
variables: {
ownerId: job.owner.id,
owner: { allow_text_message: values.allow_text_message }
}
});
if (!!updateOwnerResult.errors) {
notification["error"]({
message: t("checklist.errors.complete", {
error: JSON.stringify(result.errors)
})
});
}
}
setLoading(false); setLoading(false);
if (!!!result.errors) { if (!!!result.errors) {
@@ -189,7 +169,6 @@ export function JobChecklistForm({ insertAuditTrail, formItems, bodyshop, curren
initialValues={{ initialValues={{
...(type === "intake" && { ...(type === "intake" && {
addToProduction: true, addToProduction: true,
allow_text_message: job.owner && job.owner.allow_text_message,
scheduled_completion: scheduled_completion:
(job && job.scheduled_completion && dayjs(job.scheduled_completion)) || (job && job.scheduled_completion && dayjs(job.scheduled_completion)) ||
(job && (job &&
@@ -228,14 +207,6 @@ export function JobChecklistForm({ insertAuditTrail, formItems, bodyshop, curren
> >
<Switch disabled={readOnly} /> <Switch disabled={readOnly} />
</Form.Item> </Form.Item>
<Form.Item
name="allow_text_message"
valuePropName="checked"
label={t("checklist.labels.allow_text_message")}
disabled={readOnly}
>
<Switch disabled={readOnly} />
</Form.Item>
<Form.Item <Form.Item
name="scheduled_completion" name="scheduled_completion"
label={t("jobs.fields.scheduled_completion")} label={t("jobs.fields.scheduled_completion")}

View File

@@ -49,7 +49,7 @@ export function JobCostingModalContainer({ jobCostingModal, toggleModalVisible }
}} }}
cancelButtonProps={{ style: { display: "none" } }} cancelButtonProps={{ style: { display: "none" } }}
width="90%" width="90%"
destroyOnClose destroyOnHidden
> >
{!costingData ? ( {!costingData ? (
<LoadingSpinner loading={true} /> <LoadingSpinner loading={true} />

View File

@@ -32,7 +32,13 @@ const mapStateToProps = createStructuredSelector({
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setPrintCenterContext: (context) => dispatch(setModalContext({ context: context, modal: "printCenter" })), setPrintCenterContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "printCenter"
})
),
insertAuditTrail: ({ jobid, operation, type }) => insertAuditTrail: ({ jobid, operation, type }) =>
dispatch( dispatch(
insertAuditTrail({ insertAuditTrail({
@@ -87,7 +93,7 @@ export function JobDetailCards({ bodyshop, setPrintCenterContext, insertAuditTra
}; };
return ( return (
<Drawer open={!!selected} destroyOnClose width={drawerPercentage} placement="right" onClose={handleDrawerClose}> <Drawer open={!!selected} destroyOnHidden width={drawerPercentage} placement="right" onClose={handleDrawerClose}>
{loading ? <LoadingSpinner /> : null} {loading ? <LoadingSpinner /> : null}
{error ? <AlertComponent message={error.message} type="error" /> : null} {error ? <AlertComponent message={error.message} type="error" /> : null}
{data ? ( {data ? (

View File

@@ -80,7 +80,7 @@ export function JobEmployeeAssignments({
); );
return ( return (
<Popover destroyTooltipOnHide content={popContent} open={visibility}> <Popover destroyOnHidden content={popContent} open={visibility}>
<Spin spinning={loading}> <Spin spinning={loading}>
<DataLabel label={t("jobs.fields.employee_body")}> <DataLabel label={t("jobs.fields.employee_body")}>
{body ? ( {body ? (

View File

@@ -44,7 +44,7 @@ function JobReconciliationModalContainer({ reconciliationModal, toggleModalVisib
onOk={handleCancel} onOk={handleCancel}
onCancel={handleCancel} onCancel={handleCancel}
cancelButtonProps={{ display: "none" }} cancelButtonProps={{ display: "none" }}
destroyOnClose destroyOnHidden
className="imex-reconciliation-modal" className="imex-reconciliation-modal"
> >
{loading && <LoadingSpinner loading={loading} />} {loading && <LoadingSpinner loading={loading} />}

View File

@@ -24,7 +24,8 @@ export default function JobWatcherToggleComponent({
handleToggleSelf, handleToggleSelf,
handleRemoveWatcher, handleRemoveWatcher,
handleWatcherSelect, handleWatcherSelect,
handleTeamSelect handleTeamSelect,
isEmployee
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -66,22 +67,32 @@ export default function JobWatcherToggleComponent({
<List> <List>
<List.Item <List.Item
actions={[ actions={[
<Button <Tooltip title={!isEmployee ? t("notifications.tooltips.not-employee") : ""} placement="top">
type={isWatching ? "primary" : "default"} <span>
danger={!isWatching} <Button
icon={isWatching ? <EyeOutlined /> : <EyeFilled />} type={isWatching ? "primary" : "default"}
size="medium" danger={!isWatching}
onClick={handleToggleSelf} icon={isWatching ? <EyeOutlined /> : <EyeFilled />}
loading={adding || removing} size="medium"
> onClick={handleToggleSelf}
{isWatching ? t("notifications.labels.unwatch") : t("notifications.labels.watch")} loading={adding || removing}
</Button> disabled={!isEmployee || adding || removing}
>
{isWatching ? t("notifications.labels.unwatch") : t("notifications.labels.watch")}
</Button>
</span>
</Tooltip>
]} ]}
> >
<List.Item.Meta> <List.Item.Meta>
<Text type="secondary" style={{ marginBottom: 8, display: "block" }}> <Text type="secondary" style={{ marginBottom: 8, display: "block" }}>
{t("notifications.labels.watching-issue")} {t("notifications.labels.watching-issue")}
</Text> </Text>
{!isEmployee && (
<Text type="danger" style={{ marginBottom: 8, display: "block" }}>
{t("notifications.tooltips.not-employee")}
</Text>
)}
</List.Item.Meta> </List.Item.Meta>
</List.Item> </List.Item>
</List> </List>
@@ -98,8 +109,11 @@ export default function JobWatcherToggleComponent({
<EmployeeSearchSelectComponent <EmployeeSearchSelectComponent
style={{ minWidth: "100%" }} style={{ minWidth: "100%" }}
options={ options={
bodyshop?.employees?.filter((e) => bodyshop?.employees?.filter(
jobWatchers.every((w) => w.user_email !== e.user_email && e.active && e.user_email) (e) =>
e.user_email && // Ensure user_email is not null or undefined
e.active && // Ensure employee is active
jobWatchers.every((w) => w.user_email !== e.user_email) // Ensure not already a watcher
) || [] ) || []
} }
placeholder={t("notifications.labels.employee-search")} placeholder={t("notifications.labels.employee-search")}

View File

@@ -6,6 +6,7 @@ import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx"; import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -21,13 +22,14 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
splitKey: bodyshop && bodyshop.imexshopid splitKey: bodyshop && bodyshop.imexshopid
}); });
const userEmail = currentUser.email; const isEmployee = useIsEmployee(bodyshop, currentUser);
const jobid = job.id;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [selectedWatcher, setSelectedWatcher] = useState(null); const [selectedWatcher, setSelectedWatcher] = useState(null);
const [selectedTeam, setSelectedTeam] = useState(null); const [selectedTeam, setSelectedTeam] = useState(null);
const userEmail = currentUser.email;
const jobid = job.id;
// Fetch current watchers with refetch capability // Fetch current watchers with refetch capability
const { const {
data: watcherData, data: watcherData,
@@ -139,13 +141,13 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
}); });
const handleToggleSelf = useCallback(async () => { const handleToggleSelf = useCallback(async () => {
if (adding || removing) return; if (adding || removing || !isEmployee) return;
if (isWatching) { if (isWatching) {
await removeWatcher({ variables: { jobid, userEmail } }); await removeWatcher({ variables: { jobid, userEmail } });
} else { } else {
await addWatcher({ variables: { jobid, userEmail } }); await addWatcher({ variables: { jobid, userEmail } });
} }
}, [isWatching, addWatcher, removeWatcher, jobid, userEmail, adding, removing]); }, [isWatching, addWatcher, removeWatcher, jobid, userEmail, adding, removing, isEmployee]);
const handleRemoveWatcher = useCallback( const handleRemoveWatcher = useCallback(
async (email) => { async (email) => {
@@ -187,7 +189,16 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
setSelectedTeam(null); setSelectedTeam(null);
return; return;
} }
await Promise.all(newWatchers.map((email) => addWatcher({ variables: { jobid, userEmail: email } }))); await Promise.all(
newWatchers.map((email) =>
addWatcher({
variables: {
jobid,
userEmail: email
}
})
)
);
}, },
[jobWatchers, addWatcher, jobid, adding] [jobWatchers, addWatcher, jobid, adding]
); );
@@ -212,6 +223,7 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
handleWatcherSelect={handleWatcherSelect} handleWatcherSelect={handleWatcherSelect}
handleTeamSelect={handleTeamSelect} handleTeamSelect={handleTeamSelect}
currentUser={currentUser} currentUser={currentUser}
isEmployee={isEmployee} // Pass isEmployee to the component
/> />
); );
} }

View File

@@ -106,7 +106,12 @@ export function JobsAdminDatesChange({ insertAuditTrail, job }) {
<Form.Item label={t("jobs.fields.date_open")} name="date_open"> <Form.Item label={t("jobs.fields.date_open")} name="date_open">
<DateTimePicker /> <DateTimePicker />
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.estimate_sent_approval")} name="estimate_sent_approval">
<DateTimePicker />
</Form.Item>
<Form.Item label={t("jobs.fields.estimate_approved")} name="estimate_approved">
<DateTimePicker />
</Form.Item>
<Form.Item label={t("jobs.fields.date_scheduled")} name="date_scheduled"> <Form.Item label={t("jobs.fields.date_scheduled")} name="date_scheduled">
<DateTimePicker /> <DateTimePicker />
</Form.Item> </Form.Item>

View File

@@ -1,19 +1,19 @@
import { DownloadOutlined, SyncOutlined } from "@ant-design/icons"; import { DownloadOutlined, SyncOutlined } from "@ant-design/icons";
import { Button, Card, Input, Space, Table } from "antd"; import { Button, Card, Input, Space, Table } from "antd";
import axios from "axios"; import axios from "axios";
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 { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { selectPartnerVersion } from "../../redux/application/application.selectors"; import { selectPartnerVersion } from "../../redux/application/application.selectors";
import { alphaSort } from "../../utils/sorters"; import { alphaSort } from "../../utils/sorters";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser //currentUser: selectCurrentUser
partnerVersion: selectPartnerVersion partnerVersion: selectPartnerVersion
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export default connect(mapStateToProps, mapDispatchToProps)(JobsAvailableScan); export default connect(mapStateToProps, mapDispatchToProps)(JobsAvailableScan);
@@ -126,6 +126,7 @@ export function JobsAvailableScan({ partnerVersion, refetch }) {
onClick={() => { onClick={() => {
scanEstimates(); scanEstimates();
}} }}
id="scan-estimates-button"
> >
<SyncOutlined /> <SyncOutlined />
</Button> </Button>

View File

@@ -4,11 +4,12 @@ import { Col, Row } 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, { useCallback, useEffect, useState } from "react"; import { useCallback, 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 { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
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 {
DELETE_AVAILABLE_JOB, DELETE_AVAILABLE_JOB,
@@ -33,7 +34,6 @@ import OwnerFindModalContainer from "../owner-find-modal/owner-find-modal.contai
import { GetSupplementDelta } from "./jobs-available-supplement.estlines.util"; import { GetSupplementDelta } from "./jobs-available-supplement.estlines.util";
import HeaderFields from "./jobs-available-supplement.headerfields"; import HeaderFields from "./jobs-available-supplement.headerfields";
import JobsAvailableTableComponent from "./jobs-available-table.component"; import JobsAvailableTableComponent from "./jobs-available-table.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -195,7 +195,7 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
await deleteJob({ await deleteJob({
variables: { id: estData.id } variables: { id: estData.id }
}).then((r) => { }).then(() => {
refetch(); refetch();
setInsertLoading(false); setInsertLoading(false);
}); });
@@ -315,7 +315,7 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
deleteJob({ deleteJob({
variables: { id: estData.id } variables: { id: estData.id }
}).then((r) => { }).then(() => {
refetch(); refetch();
setInsertLoading(false); setInsertLoading(false);
}); });
@@ -372,7 +372,7 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
loadEstData({ variables: { id: record.id } }); loadEstData({ variables: { id: record.id } });
modalSearchState[1](record.clm_no); modalSearchState[1](record.clm_no);
setJobModalVisible(true); setJobModalVisible(true);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -456,7 +456,7 @@ function replaceEmpty(someObj, replaceValue = null) {
return JSON.parse(temp); return JSON.parse(temp);
} }
async function CheckTaxRatesUSA(estData, bodyshop) { async function CheckTaxRatesUSA(estData) {
if (!estData.parts_tax_rates?.PAM) { if (!estData.parts_tax_rates?.PAM) {
estData.parts_tax_rates.PAM = estData.parts_tax_rates.PAC; estData.parts_tax_rates.PAM = estData.parts_tax_rates.PAC;
} }
@@ -568,7 +568,7 @@ async function CheckTaxRates(estData, bodyshop) {
}); });
//} //}
} }
function ResolveCCCLineIssues(estData, bodyshop) { function ResolveCCCLineIssues(estData) {
//Find all misc amounts, populate them to the act price. //Find all misc amounts, populate them to the act price.
//This needs to be done before cleansing unq_seq since some misc prices could move over. //This needs to be done before cleansing unq_seq since some misc prices could move over.
estData.joblines.data.forEach((line) => { estData.joblines.data.forEach((line) => {
@@ -585,6 +585,9 @@ function ResolveCCCLineIssues(estData, bodyshop) {
// line.notes += ` | ET/UT Update (prev = ${line.mod_lbr_ty})`; // line.notes += ` | ET/UT Update (prev = ${line.mod_lbr_ty})`;
line.mod_lbr_ty = "LAR"; line.mod_lbr_ty = "LAR";
} }
if (line.mod_lbr_ty === "OTSL") {
line.mod_lbr_ty = line.mod_lbr_hrs === 0 ? null : "LAB";
}
} }
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { Form, Input, Switch } from "antd"; import { Form, Input } from "antd";
import React, { useContext } from "react"; import React, { useContext } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import JobCreateContext from "../../pages/jobs-create/jobs-create.context"; import JobCreateContext from "../../pages/jobs-create/jobs-create.context";
@@ -129,13 +129,6 @@ export default function JobsCreateOwnerInfoNewComponent() {
<Form.Item label={t("owners.fields.preferred_contact")} name={["owner", "data", "preferred_contact"]}> <Form.Item label={t("owners.fields.preferred_contact")} name={["owner", "data", "preferred_contact"]}>
<Input disabled={!state.owner.new} /> <Input disabled={!state.owner.new} />
</Form.Item> </Form.Item>
<Form.Item
label={t("owners.fields.allow_text_message")}
valuePropName="checked"
name={["owner", "data", "allow_text_message"]}
>
<Switch disabled={!state.owner.new} />
</Form.Item>
</LayoutFormRow> </LayoutFormRow>
</div> </div>
); );

View File

@@ -1,5 +1,5 @@
import { Card, Input, Table } from "antd"; import { Card, Input, Table } from "antd";
import React, { useContext, useState } from "react"; import { useContext, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import JobCreateContext from "../../pages/jobs-create/jobs-create.context"; import JobCreateContext from "../../pages/jobs-create/jobs-create.context";
import PhoneFormatter from "../../utils/PhoneFormatter"; import PhoneFormatter from "../../utils/PhoneFormatter";
@@ -91,6 +91,7 @@ export default function JobsCreateOwnerInfoSearchComponent({ loading, owners })
}); });
}} }}
enterButton enterButton
id="search-owner"
/> />
} }
> >
@@ -112,9 +113,9 @@ export default function JobsCreateOwnerInfoSearchComponent({ loading, owners })
type: "radio", type: "radio",
selectedRowKeys: [state.owner.selectedid] selectedRowKeys: [state.owner.selectedid]
}} }}
onRow={(record, rowIndex) => { onRow={(record) => {
return { return {
onClick: (event) => { onClick: () => {
if (record) { if (record) {
if (record.id) { if (record.id) {
setState({ setState({

View File

@@ -72,7 +72,7 @@ export default function JobsCreateVehicleInfoPredefined({ disabled, form }) {
open={open} open={open}
placement="left" placement="left"
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
destroyTooltipOnHide destroyOnHidden
> >
<SearchOutlined style={{ cursor: "pointer" }} /> <SearchOutlined style={{ cursor: "pointer" }} />
</Popover> </Popover>

View File

@@ -1,9 +1,9 @@
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { Card, Input, Space, Table } from "antd"; import { Card, Input, Space, Table } from "antd";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { alphaSort } from "../../utils/sorters";
import JobCreateContext from "../../pages/jobs-create/jobs-create.context"; import JobCreateContext from "../../pages/jobs-create/jobs-create.context";
import { alphaSort } from "../../utils/sorters";
import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component"; import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component";
export default function JobsCreateVehicleInfoSearchComponent({ loading, vehicles }) { export default function JobsCreateVehicleInfoSearchComponent({ loading, vehicles }) {
@@ -63,6 +63,7 @@ export default function JobsCreateVehicleInfoSearchComponent({ loading, vehicles
}); });
}} }}
enterButton enterButton
id="search-vehicle"
/> />
</Space> </Space>
} }
@@ -91,9 +92,9 @@ export default function JobsCreateVehicleInfoSearchComponent({ loading, vehicles
type: "radio", type: "radio",
selectedRowKeys: [state.vehicle.selectedid] selectedRowKeys: [state.vehicle.selectedid]
}} }}
onRow={(record, rowIndex) => { onRow={(record) => {
return { return {
onClick: (event) => { onClick: () => {
if (record) { if (record) {
if (record.id) { if (record.id) {
setState({ setState({

View File

@@ -7,6 +7,7 @@ import { selectJobReadOnly } from "../../redux/application/application.selectors
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
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,
@@ -40,6 +41,20 @@ export function JobsDetailDatesComponent({ jobRO, job, bodyshop }) {
<Form.Item label={t("jobs.fields.date_rentalresp")} name="date_rentalresp"> <Form.Item label={t("jobs.fields.date_rentalresp")} name="date_rentalresp">
<DateTimePicker disabled={jobRO} /> <DateTimePicker disabled={jobRO} />
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.estimate_sent_approval")} name="estimate_sent_approval">
<DateTimePicker
disabled={true}
value={job.estimate_sent_approval ? dayjs(job.estimate_sent_approval) : null}
placeholder={t("general.labels.na")}
/>
</Form.Item>
<Form.Item label={t("jobs.fields.estimate_approved")} name="estimate_approved">
<DateTimePicker
disabled={true}
value={job.estimate_approved ? dayjs(job.estimate_approved) : null}
placeholder={t("general.labels.na")}
/>
</Form.Item>
</FormRow> </FormRow>
<FormRow header={t("jobs.forms.scheddates")}> <FormRow header={t("jobs.forms.scheddates")}>
@@ -76,21 +91,15 @@ export function JobsDetailDatesComponent({ jobRO, job, bodyshop }) {
<DateTimePicker disabled={jobRO} /> <DateTimePicker disabled={jobRO} />
</Form.Item> </Form.Item>
<Form.Item shouldUpdate> <Form.Item shouldUpdate>
{() => { {() => (
return ( <Form.Item
<Form.Item label={t("jobs.fields.actual_completion")}
label={t("jobs.fields.actual_completion")} name="actual_completion"
name="actual_completion" rules={[{ required: jobInPostProduction }]}
rules={[ >
{ <DateTimePicker disabled={jobRO} />
required: jobInPostProduction </Form.Item>
} )}
]}
>
<DateTimePicker disabled={jobRO} />
</Form.Item>
);
}}
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.scheduled_delivery")} name="scheduled_delivery"> <Form.Item label={t("jobs.fields.scheduled_delivery")} name="scheduled_delivery">
<DateTimePicker disabled={jobRO} /> <DateTimePicker disabled={jobRO} />
@@ -103,15 +112,12 @@ export function JobsDetailDatesComponent({ jobRO, job, bodyshop }) {
<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 || jobRO} />
</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 || jobRO} />
</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 || jobRO} />
</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 || jobRO} />
</Form.Item> </Form.Item>

View File

@@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { auth, logImEXEvent } from "../../firebase/firebase.utils"; import { auth, logImEXEvent } from "../../firebase/firebase.utils";
import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries"; import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries";
@@ -32,7 +33,6 @@ import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
import AddToProduction from "./jobs-detail-header-actions.addtoproduction.util"; import AddToProduction from "./jobs-detail-header-actions.addtoproduction.util";
import DuplicateJob from "./jobs-detail-header-actions.duplicate.util"; import DuplicateJob from "./jobs-detail-header-actions.duplicate.util";
import JobsDetailHeaderActionsToggleProduction from "./jobs-detail-header-actions.toggle-production"; import JobsDetailHeaderActionsToggleProduction from "./jobs-detail-header-actions.toggle-production";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -1078,17 +1078,26 @@ export function JobsDetailHeaderActions({
menuItems.push({ menuItems.push({
key: "deletejob", key: "deletejob",
id: "job-actions-deletejob", id: "job-actions-deletejob",
label: ( label:
<Popconfirm job.job_watchers.length === 0 ? (
title={t("jobs.labels.deleteconfirm")} <Popconfirm
okText={t("general.labels.yes")} title={t("jobs.labels.deleteconfirm")}
cancelText={t("general.labels.no")} okText={t("general.labels.yes")}
onClick={(e) => e.stopPropagation()} cancelText={t("general.labels.no")}
onConfirm={handleDeleteJob} onClick={(e) => e.stopPropagation()}
> onConfirm={handleDeleteJob}
{t("menus.jobsactions.deletejob")} >
</Popconfirm> {t("menus.jobsactions.deletejob")}
) </Popconfirm>
) : (
<Popconfirm
title={t("jobs.labels.deletewatchers")}
onClick={(e) => e.stopPropagation()}
showCancel={false}
>
{t("menus.jobsactions.deletejob")}
</Popconfirm>
)
}); });
} }
@@ -1109,8 +1118,8 @@ export function JobsDetailHeaderActions({
<RbacWrapper action="jobs:void" noauth> <RbacWrapper action="jobs:void" noauth>
<Popconfirm <Popconfirm
title={t("jobs.labels.voidjob")} title={t("jobs.labels.voidjob")}
okText="Yes" okText={t("general.labels.yes")}
cancelText="No" cancelText={t("general.labels.no")}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onConfirm={handleVoidJob} onConfirm={handleVoidJob}
> >

View File

@@ -167,7 +167,18 @@ export function JobsDetailHeaderActionsToggleProduction({
<FormDateTimePickerComponent disabled={jobRO} /> <FormDateTimePickerComponent disabled={jobRO} />
</Form.Item> </Form.Item>
<Form.Item name={["actual_delivery"]} label={t("jobs.fields.actual_delivery")}> <Form.Item
name={["actual_delivery"]}
label={t("jobs.fields.actual_delivery")}
rules={[
{
required: bodyshop.deliverchecklist.actual_delivery
? bodyshop.deliverchecklist.actual_delivery
: false
//message: t("general.validation.required"),
}
]}
>
<FormDateTimePickerComponent disabled={jobRO} /> <FormDateTimePickerComponent disabled={jobRO} />
</Form.Item> </Form.Item>
</> </>

View File

@@ -1,15 +1,20 @@
import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined, WarningFilled } from "@ant-design/icons"; import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined, WarningFilled } from "@ant-design/icons";
import { Card, Col, Divider, Row, Space, Tag, Tooltip } from "antd"; import { useMutation } from "@apollo/client";
import React, { useState } from "react"; import { Card, Checkbox, Col, Divider, Row, Space, Tag, Tooltip } from "antd";
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 } from "react-router-dom"; import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { UPDATE_JOB } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions.js";
import { selectJobReadOnly } from "../../redux/application/application.selectors"; import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions"; import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
import CurrencyFormatter from "../../utils/CurrencyFormatter"; import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateTimeFormatter } from "../../utils/DateFormatter"; import { DateTimeFormatter, DateTimeFormatterFunction } from "../../utils/DateFormatter";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import PhoneNumberFormatter from "../../utils/PhoneFormatter"; import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import ChatOpenButton from "../chat-open-button/chat-open-button.component"; import ChatOpenButton from "../chat-open-button/chat-open-button.component";
@@ -29,41 +34,73 @@ const mapStateToProps = createStructuredSelector({
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setPrintCenterContext: (context) => dispatch(setModalContext({ context: context, modal: "printCenter" })) setPrintCenterContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "printCenter"
})
),
insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
insertAuditTrail({
jobid,
operation,
type
})
)
}); });
const colSpan = { const colSpan = {
xs: { xs: { span: 24 },
span: 24 sm: { span: 24 },
}, md: { span: 12 },
sm: { lg: { span: 6 },
span: 24 xl: { span: 6 }
},
md: {
span: 12
},
lg: {
span: 6
},
xl: {
span: 6
}
}; };
export function JobsDetailHeader({ job, bodyshop, disabled }) { export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { notification } = useNotification();
const [notesClamped, setNotesClamped] = useState(true); const [notesClamped, setNotesClamped] = useState(true);
const vehicleTitle = `${job.v_model_yr || ""} ${job.v_color || ""} const [updateJob] = useMutation(UPDATE_JOB);
${job.v_make_desc || ""} const vehicleTitle =
${job.v_model_desc || ""}`.trim(); `${job.v_model_yr || ""} ${job.v_color || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim();
const bodyHrs = job.joblines.filter((j) => j.mod_lbr_ty !== "LAR").reduce((acc, val) => acc + val.mod_lb_hrs, 0); const bodyHrs = job.joblines.filter((j) => j.mod_lbr_ty !== "LAR").reduce((acc, val) => acc + val.mod_lb_hrs, 0);
const refinishHrs = job.joblines const refinishHrs = job.joblines
.filter((line) => line.mod_lbr_ty === "LAR") .filter((line) => line.mod_lbr_ty === "LAR")
.reduce((acc, val) => acc + val.mod_lb_hrs, 0); .reduce((acc, val) => acc + val.mod_lb_hrs, 0);
const ownerTitle = OwnerNameDisplayFunction(job).trim(); const ownerTitle = OwnerNameDisplayFunction(job).trim();
// Handle checkbox changes
const handleCheckboxChange = async (field, checked) => {
const value = checked ? dayjs().toISOString() : null;
try {
const ret = await updateJob({
variables: {
jobId: job.id,
job: { [field]: value }
},
refetchQueries: ["GET_JOB_BY_PK"],
awaitRefetchQueries: true
});
insertAuditTrail({
jobid: job.id,
operation: AuditTrailMapping.jobfieldchange(
field,
ret.data.update_jobs.returning[0][field]
? DateTimeFormatterFunction(ret.data.update_jobs.returning[0][field])
: checked
),
type: "jobfieldchange"
});
} catch (error) {
notification.error({
message: t("jobs.errors.saving", { error: error.message })
});
}
};
return ( return (
<Row gutter={[16, 16]} style={{ alignItems: "stretch" }}> <Row gutter={[16, 16]} style={{ alignItems: "stretch" }}>
<Col {...colSpan}> <Col {...colSpan}>
@@ -72,11 +109,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
<DataLabel label={t("jobs.fields.status")}> <DataLabel label={t("jobs.fields.status")}>
<Space wrap> <Space wrap>
{job.status} {job.status}
{job.inproduction && ( {job.inproduction && <Tag color="#f50">{t("jobs.labels.inproduction")}</Tag>}
<Tag color="#f50" key="production">
{t("jobs.labels.inproduction")}
</Tag>
)}
{job.suspended && <PauseCircleOutlined style={{ color: "orangered" }} />} {job.suspended && <PauseCircleOutlined style={{ color: "orangered" }} />}
{job.iouparent && ( {job.iouparent && (
<Link to={`/manage/jobs/${job.iouparent}`}> <Link to={`/manage/jobs/${job.iouparent}`}>
@@ -110,7 +143,6 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
<span style={{ margin: "0rem .5rem" }}>/</span> <span style={{ margin: "0rem .5rem" }}>/</span>
<CurrencyFormatter>{job.owner_owing}</CurrencyFormatter> <CurrencyFormatter>{job.owner_owing}</CurrencyFormatter>
</DataLabel> </DataLabel>
<DataLabel label={t("jobs.fields.alt_transport")}> <DataLabel label={t("jobs.fields.alt_transport")}>
{job.alt_transport} {job.alt_transport}
<JobAltTransportChange job={job} /> <JobAltTransportChange job={job} />
@@ -127,11 +159,39 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
))} ))}
</DataLabel> </DataLabel>
)} )}
<DataLabel label={t("jobs.fields.production_vars.note")}> <DataLabel label={t("jobs.fields.production_vars.note")}>
<ProductionListColumnProductionNote record={job} /> <ProductionListColumnProductionNote record={job} />
</DataLabel> </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.special_coverage_policy && ( {job.special_coverage_policy && (
<Tag color="tomato"> <Tag color="tomato">

View File

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

View File

@@ -1,9 +1,9 @@
import { SyncOutlined } from "@ant-design/icons"; import { SyncOutlined } from "@ant-design/icons";
import { Button, Checkbox, Divider, Input, Space, Table } from "antd"; import { Button, Checkbox, Divider, Input, Space, Table } 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 dayjs from "../../utils/day";
import PhoneFormatter from "../../utils/PhoneFormatter"; import PhoneFormatter from "../../utils/PhoneFormatter";
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component"; import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component"; import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
@@ -223,9 +223,9 @@ export default function JobsFindModalComponent({
type: "radio", type: "radio",
selectedRowKeys: [selectedJob] selectedRowKeys: [selectedJob]
}} }}
onRow={(record, rowIndex) => { onRow={(record) => {
return { return {
onClick: (event) => { onClick: () => {
handleOnRowClick(record); handleOnRowClick(record);
} }
}; };
@@ -241,15 +241,17 @@ export default function JobsFindModalComponent({
overrideHeaders: e.target.checked overrideHeaders: e.target.checked
}) })
} }
id="override_header"
> >
{t("jobs.labels.override_header")} {t("jobs.labels.override_header")}
</Checkbox> </Checkbox>
<Checkbox checked={partsQueueToggle} onChange={(e) => setPartsQueueToggle(e.target.checked)}> <Checkbox checked={partsQueueToggle} onChange={(e) => setPartsQueueToggle(e.target.checked)} id="parts_queue_toggle">
{t("bodyshop.fields.md_functionality_toggles.parts_queue_toggle")} {t("bodyshop.fields.md_functionality_toggles.parts_queue_toggle")}
</Checkbox> </Checkbox>
<Checkbox <Checkbox
checked={updateSchComp.checked} checked={updateSchComp.checked}
onChange={(e) => setSchComp({ ...updateSchComp, checked: e.target.checked })} onChange={(e) => setSchComp({ ...updateSchComp, checked: e.target.checked })}
id="update_scheduled_completion"
> >
{t("jobs.labels.update_scheduled_completion")} {t("jobs.labels.update_scheduled_completion")}
</Checkbox> </Checkbox>
@@ -261,6 +263,7 @@ export default function JobsFindModalComponent({
onChange={(e) => { onChange={(e) => {
setSchComp({ ...updateSchComp, scheduled_completion: e }); setSchComp({ ...updateSchComp, scheduled_completion: e });
}} }}
id="scheduled_completion_date_time_picker"
/> />
) : null} ) : null}
<Checkbox <Checkbox
@@ -273,6 +276,7 @@ export default function JobsFindModalComponent({
automatic: true automatic: true
}); });
}} }}
id="calculate_scheduled_completion"
> >
{t("jobs.labels.calc_scheuled_completion")} {t("jobs.labels.calc_scheuled_completion")}
</Checkbox> </Checkbox>

View File

@@ -1,6 +1,5 @@
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import { Modal } from "antd"; import { Modal } from "antd";
import React 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";
@@ -65,8 +64,8 @@ export default connect(
<Modal <Modal
title={t("jobs.labels.existing_jobs")} title={t("jobs.labels.existing_jobs")}
width={"80%"} width={"80%"}
destroyOnClose destroyOnHidden
okButtonProps={{ disabled: selectedJob ? false : true }} okButtonProps={{ disabled: selectedJob ? false : true, id: "jobs-find-modal-container-ok" }}
{...modalProps} {...modalProps}
> >
{loading ? <LoadingSpinner /> : null} {loading ? <LoadingSpinner /> : null}

View File

@@ -20,7 +20,14 @@ const mapStateToProps = createStructuredSelector({
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("noteUpsert")), toggleModalVisible: () => dispatch(toggleModalVisible("noteUpsert")),
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type })) insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
insertAuditTrail({
jobid,
operation,
type
})
)
}); });
export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleModalVisible, insertAuditTrail }) { export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleModalVisible, insertAuditTrail }) {
@@ -123,7 +130,7 @@ export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleM
onCancel={() => { onCancel={() => {
toggleModalVisible(); toggleModalVisible();
}} }}
destroyOnClose destroyOnHidden
> >
<Form form={form} onFinish={handleFinish} layout="vertical"> <Form form={form} onFinish={handleFinish} layout="vertical">
<NoteUpsertModalComponent form={form} /> <NoteUpsertModalComponent form={form} />

View File

@@ -1,11 +1,11 @@
import { Virtuoso } from "react-virtuoso"; import { Virtuoso } from "react-virtuoso";
import { Badge, Button, Space, Spin, Switch, Tooltip, Typography } from "antd"; import { Alert, Badge, Button, Space, Spin, Switch, Tooltip, Typography } from "antd";
import { CheckCircleFilled, CheckCircleOutlined, EyeFilled, EyeOutlined } from "@ant-design/icons"; import { CheckCircleFilled, CheckCircleOutlined, EyeFilled, EyeOutlined } from "@ant-design/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import "./notification-center.styles.scss"; import "./notification-center.styles.scss";
import day from "../../utils/day.js"; import day from "../../utils/day.js";
import { forwardRef, useRef, useEffect } from "react"; import { forwardRef, useEffect, useRef } from "react";
import { DateTimeFormat } from "../../utils/DateFormatter.jsx"; import { DateTimeFormat } from "../../utils/DateFormatter.jsx";
const { Text, Title } = Typography; const { Text, Title } = Typography;
@@ -26,7 +26,8 @@ const NotificationCenterComponent = forwardRef(
markAllRead, markAllRead,
loadMore, loadMore,
onNotificationClick, onNotificationClick,
unreadCount unreadCount,
isEmployee
}, },
ref ref
) => { ) => {
@@ -93,7 +94,12 @@ const NotificationCenterComponent = forwardRef(
) : ( ) : (
<EyeOutlined className="notification-toggle-icon" /> <EyeOutlined className="notification-toggle-icon" />
)} )}
<Switch checked={showUnreadOnly} onChange={(checked) => toggleUnreadOnly(checked)} size="small" /> <Switch
checked={showUnreadOnly}
onChange={(checked) => toggleUnreadOnly(checked)}
size="small"
disabled={!isEmployee}
/>
</Space> </Space>
</Tooltip> </Tooltip>
<Tooltip title={t("notifications.labels.mark-all-read")}> <Tooltip title={t("notifications.labels.mark-all-read")}>
@@ -106,14 +112,20 @@ const NotificationCenterComponent = forwardRef(
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<Virtuoso {!isEmployee ? (
ref={virtuosoRef} <div style={{ padding: 10 }}>
style={{ height: "400px", width: "100%" }} <Alert message={t("notifications.labels.employee-notification")} type="warning" />
data={notifications} </div>
totalCount={notifications.length} ) : (
endReached={loadMore} <Virtuoso
itemContent={renderNotification} ref={virtuosoRef}
/> style={{ height: "400px", width: "100%" }}
data={notifications}
totalCount={notifications.length}
endReached={loadMore}
itemContent={renderNotification}
/>
)}
</div> </div>
); );
} }

View File

@@ -4,9 +4,10 @@ import { connect } from "react-redux";
import NotificationCenterComponent from "./notification-center.component"; import NotificationCenterComponent from "./notification-center.component";
import { GET_NOTIFICATIONS } from "../../graphql/notifications.queries"; import { GET_NOTIFICATIONS } from "../../graphql/notifications.queries";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
import day from "../../utils/day.js"; import day from "../../utils/day.js";
import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/useSocket.js"; import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
// This will be used to poll for notifications when the socket is disconnected // This will be used to poll for notifications when the socket is disconnected
const NOTIFICATION_POLL_INTERVAL_SECONDS = 60; const NOTIFICATION_POLL_INTERVAL_SECONDS = 60;
@@ -17,17 +18,18 @@ const NOTIFICATION_POLL_INTERVAL_SECONDS = 60;
* @param onClose * @param onClose
* @param bodyshop * @param bodyshop
* @param unreadCount * @param unreadCount
* @param currentUser
* @returns {JSX.Element} * @returns {JSX.Element}
* @constructor * @constructor
*/ */
const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }) => { const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount, currentUser }) => {
const [showUnreadOnly, setShowUnreadOnly] = useState(false); const [showUnreadOnly, setShowUnreadOnly] = useState(false);
const [notifications, setNotifications] = useState([]); const [notifications, setNotifications] = useState([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { isConnected, markNotificationRead, markAllNotificationsRead } = useSocket(); const { isConnected, markNotificationRead, markAllNotificationsRead } = useSocket();
const notificationRef = useRef(null); const notificationRef = useRef(null);
const userAssociationId = bodyshop?.associations?.[0]?.id; const userAssociationId = bodyshop?.associations?.[0]?.id;
const isEmployee = useIsEmployee(bodyshop, currentUser);
const baseWhereClause = useMemo(() => { const baseWhereClause = useMemo(() => {
return { associationid: { _eq: userAssociationId } }; return { associationid: { _eq: userAssociationId } };
@@ -51,7 +53,7 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }
fetchPolicy: "cache-and-network", fetchPolicy: "cache-and-network",
notifyOnNetworkStatusChange: true, notifyOnNetworkStatusChange: true,
pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(), pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(),
skip: !userAssociationId, skip: !userAssociationId || !isEmployee,
onError: (err) => { onError: (err) => {
console.error(`Error polling Notifications: ${err?.message || ""}`); console.error(`Error polling Notifications: ${err?.message || ""}`);
setTimeout(() => refetch(), day.duration(2, "seconds").asMilliseconds()); setTimeout(() => refetch(), day.duration(2, "seconds").asMilliseconds());
@@ -71,7 +73,7 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }
}, [visible, onClose]); }, [visible, onClose]);
useEffect(() => { useEffect(() => {
if (data?.notifications) { if (data?.notifications && isEmployee) {
const processedNotifications = data.notifications const processedNotifications = data.notifications
.map((notif) => { .map((notif) => {
let scenarioText; let scenarioText;
@@ -101,11 +103,13 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }
}) })
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
setNotifications(processedNotifications); setNotifications(processedNotifications);
} else if (!isEmployee) {
setNotifications([]); // Clear notifications if not an employee
} }
}, [data]); }, [data, isEmployee]);
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if (!queryLoading && data?.notifications.length) { if (!queryLoading && data?.notifications.length && isEmployee) {
setIsLoading(true); // Show spinner during fetchMore setIsLoading(true); // Show spinner during fetchMore
fetchMore({ fetchMore({
variables: { offset: data.notifications.length, where: whereClause }, variables: { offset: data.notifications.length, where: whereClause },
@@ -121,13 +125,14 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }
}) })
.finally(() => setIsLoading(false)); // Hide spinner when done .finally(() => setIsLoading(false)); // Hide spinner when done
} }
}, [data?.notifications?.length, fetchMore, queryLoading, whereClause]); }, [data?.notifications?.length, fetchMore, queryLoading, whereClause, isEmployee]);
const handleToggleUnreadOnly = (value) => { const handleToggleUnreadOnly = (value) => {
setShowUnreadOnly(value); setShowUnreadOnly(value);
}; };
const handleMarkAllRead = useCallback(() => { const handleMarkAllRead = useCallback(() => {
if (!isEmployee) return; // Do nothing if not an employee
setIsLoading(true); setIsLoading(true);
markAllNotificationsRead() markAllNotificationsRead()
.then(() => { .then(() => {
@@ -147,7 +152,7 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }
}) })
.catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`)) .catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`))
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}, [markAllNotificationsRead, userAssociationId, showUnreadOnly]); }, [markAllNotificationsRead, userAssociationId, showUnreadOnly, isEmployee]);
const handleNotificationClick = useCallback( const handleNotificationClick = useCallback(
(notificationId) => { (notificationId) => {
@@ -170,17 +175,18 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }
); );
useEffect(() => { useEffect(() => {
if (visible && !isConnected) { if (visible && !isConnected && isEmployee) {
setIsLoading(true); setIsLoading(true);
refetch() refetch()
.catch((err) => console.error(`Error re-fetching notifications: ${err?.message || ""}`)) .catch((err) => console.error(`Error re-fetching notifications: ${err?.message || ""}`))
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
} }
}, [visible, isConnected, refetch]); }, [visible, isConnected, refetch, isEmployee]);
return ( return (
<NotificationCenterComponent <NotificationCenterComponent
ref={notificationRef} ref={notificationRef}
isEmployee={isEmployee}
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
notifications={notifications} notifications={notifications}
@@ -196,7 +202,8 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }
}; };
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop,
currentUser: selectCurrentUser
}); });
export default connect(mapStateToProps, null)(NotificationCenterContainer); export default connect(mapStateToProps, null)(NotificationCenterContainer);

View File

@@ -1,10 +1,10 @@
import { useMutation, useQuery } from "@apollo/client"; import { useMutation, useQuery } from "@apollo/client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, Card, Checkbox, Divider, Form, Space, Switch, Table, Typography } from "antd"; import { Alert, Button, Card, Checkbox, Divider, Form, Space, Switch, Table, Typography } from "antd";
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 { selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import { import {
QUERY_NOTIFICATION_SETTINGS, QUERY_NOTIFICATION_SETTINGS,
@@ -16,14 +16,16 @@ import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx"; import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
/** /**
* Notifications Settings Form * Notifications Settings Form
* @param currentUser * @param currentUser
* @param bodyshop
* @returns {JSX.Element} * @returns {JSX.Element}
* @constructor * @constructor
*/ */
const NotificationSettingsForm = ({ currentUser }) => { const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [initialValues, setInitialValues] = useState({}); const [initialValues, setInitialValues] = useState({});
@@ -31,6 +33,7 @@ const NotificationSettingsForm = ({ currentUser }) => {
const [autoAddEnabled, setAutoAddEnabled] = useState(false); const [autoAddEnabled, setAutoAddEnabled] = useState(false);
const [initialAutoAdd, setInitialAutoAdd] = useState(false); const [initialAutoAdd, setInitialAutoAdd] = useState(false);
const notification = useNotification(); const notification = useNotification();
const isEmployee = useIsEmployee(bodyshop, currentUser);
// Fetch notification settings and notifications_autoadd // Fetch notification settings and notifications_autoadd
const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, { const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, {
@@ -199,6 +202,11 @@ const NotificationSettingsForm = ({ currentUser }) => {
</Space> </Space>
} }
> >
{!isEmployee && (
<div style={{ width: "100%", marginBottom: "10px" }}>
<Alert message={t("notifications.labels.employee-notification")} type="warning" />
</div>
)}
<Table dataSource={dataSource} columns={columns} pagination={false} bordered rowKey="key" /> <Table dataSource={dataSource} columns={columns} pagination={false} bordered rowKey="key" />
<Divider /> <Divider />
</Card> </Card>
@@ -209,11 +217,13 @@ const NotificationSettingsForm = ({ currentUser }) => {
NotificationSettingsForm.propTypes = { NotificationSettingsForm.propTypes = {
currentUser: PropTypes.shape({ currentUser: PropTypes.shape({
email: PropTypes.string.isRequired email: PropTypes.string.isRequired
}).isRequired }).isRequired,
bodyshop: PropTypes.object.isRequired
}; };
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser currentUser: selectCurrentUser,
bodyshop: selectBodyshop
}); });
export default connect(mapStateToProps)(NotificationSettingsForm); export default connect(mapStateToProps)(NotificationSettingsForm);

View File

@@ -1,14 +1,15 @@
import { Form, Input, Switch } from "antd"; import { Form, Input, Tooltip } from "antd";
import React from "react"; import { CloseCircleFilled } from "@ant-design/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component"; import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import FormItemEmail from "../form-items-formatted/email-form-item.component"; import FormItemEmail from "../form-items-formatted/email-form-item.component";
import FormItemPhone, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
export default function OwnerDetailFormComponent({ form, loading }) { export default function OwnerDetailFormComponent({ form, loading, isPhone1OptedOut, isPhone2OptedOut }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { getFieldValue } = form; const { getFieldValue } = form;
return ( return (
<div> <div>
<FormFieldsChanged form={form} /> <FormFieldsChanged form={form} />
@@ -26,7 +27,7 @@ export default function OwnerDetailFormComponent({ form, loading }) {
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item label={t("owners.fields.accountingid")} name="accountingid"> <Form.Item label={t("owners.fields.accountingid")} name="accountingid">
<Input disabled/> <Input disabled />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow header={t("owners.forms.address")}> <LayoutFormRow header={t("owners.forms.address")}>
@@ -50,9 +51,6 @@ export default function OwnerDetailFormComponent({ form, loading }) {
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow header={t("owners.forms.contact")}> <LayoutFormRow header={t("owners.forms.contact")}>
<Form.Item label={t("owners.fields.allow_text_message")} name="allow_text_message" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item <Form.Item
label={t("owners.fields.ownr_ea")} label={t("owners.fields.ownr_ea")}
name="ownr_ea" name="ownr_ea"
@@ -65,19 +63,55 @@ export default function OwnerDetailFormComponent({ form, loading }) {
> >
<FormItemEmail email={getFieldValue("ownr_ea")} /> <FormItemEmail email={getFieldValue("ownr_ea")} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item label={t("owners.fields.ownr_ph1")} style={{ marginBottom: 0 }}>
label={t("owners.fields.ownr_ph1")} <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
name="ownr_ph1" <Form.Item
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph1")]} name="ownr_ph1"
> noStyle
<FormItemPhone /> rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph1")]}
>
<Input style={{ flex: 1, minWidth: "150px" }} />
</Form.Item>
{isPhone1OptedOut && (
<Tooltip title={t("consent.text_body")}>
<CloseCircleFilled
style={{
color: "#ff4d4f",
fontSize: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%"
}}
/>
</Tooltip>
)}
</div>
</Form.Item> </Form.Item>
<Form.Item <Form.Item label={t("owners.fields.ownr_ph2")} style={{ marginBottom: 0 }}>
label={t("owners.fields.ownr_ph2")} <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
name="ownr_ph2" <Form.Item
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph2")]} name="ownr_ph2"
> noStyle
<FormItemPhone /> rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph2")]}
>
<Input style={{ flex: 1, minWidth: "150px" }} />
</Form.Item>
{isPhone2OptedOut && (
<Tooltip title={t("consent.text_body")}>
<CloseCircleFilled
style={{
color: "#ff4d4f",
fontSize: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%"
}}
/>
</Tooltip>
)}
</div>
</Form.Item> </Form.Item>
<Form.Item label={t("owners.fields.preferred_contact")} name="preferred_contact"> <Form.Item label={t("owners.fields.preferred_contact")} name="preferred_contact">
<Input /> <Input />

View File

@@ -1,69 +1,115 @@
import { Button, Form, Popconfirm } from "antd"; import { Button, Form, Popconfirm } from "antd";
import { PageHeader } from "@ant-design/pro-layout"; import { PageHeader } from "@ant-design/pro-layout";
import { useEffect, useState } from "react";
import React, { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useMutation } from "@apollo/client"; import { useApolloClient, useMutation } from "@apollo/client";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { DELETE_OWNER, UPDATE_OWNER } from "../../graphql/owners.queries"; import { DELETE_OWNER, UPDATE_OWNER } from "../../graphql/owners.queries";
import { selectBodyshop } from "../../redux/user/user.selectors"; // Adjust path
import { phoneNumberOptOutService } from "../../utils/phoneOptOutService.js"; // Adjust path
import OwnerDetailFormComponent from "./owner-detail-form.component"; import OwnerDetailFormComponent from "./owner-detail-form.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { phone } from "phone"; // Import phone utility for formatting
function OwnerDetailFormContainer({ owner, refetch }) { // Connect to Redux to access bodyshop
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
function OwnerDetailFormContainer({ owner, refetch, bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const history = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [optedOutPhones, setOptedOutPhones] = useState(new Set());
const [updateOwner] = useMutation(UPDATE_OWNER); const [updateOwner] = useMutation(UPDATE_OWNER);
const [deleteOwner] = useMutation(DELETE_OWNER); const [deleteOwner] = useMutation(DELETE_OWNER);
const notification = useNotification(); const notification = useNotification();
const apolloClient = useApolloClient();
// Fetch opt-out status on mount
useEffect(() => {
const fetchOptOutStatus = async () => {
if (bodyshop?.id && bodyshop?.messagingservicesid && (owner?.ownr_ph1 || owner?.ownr_ph2)) {
const phoneNumbers = [owner.ownr_ph1, owner.ownr_ph2].filter(Boolean);
const optOutSet = await phoneNumberOptOutService(apolloClient, bodyshop.id, phoneNumbers);
setOptedOutPhones(optOutSet);
} else {
setOptedOutPhones(new Set());
}
};
fetchOptOutStatus();
}, [apolloClient, bodyshop?.id, bodyshop?.messagingservicesid, owner?.ownr_ph1, owner?.ownr_ph2]);
// Reset form fields when owner changes
useEffect(() => {
form.setFieldsValue({
ownr_ph1: owner?.ownr_ph1,
ownr_ph2: owner?.ownr_ph2,
...owner
});
}, [owner, form]);
const handleDelete = async () => { const handleDelete = async () => {
setLoading(true); setLoading(true);
const result = await deleteOwner({ try {
variables: { id: owner.id } const result = await deleteOwner({
}); variables: { id: owner.id }
console.log(result); });
if (result.errors) { if (result.errors) {
notification["error"]({ notification.error({
message: t("owners.errors.deleting", {
error: JSON.stringify(result.errors)
})
});
} else {
notification.success({
message: t("owners.successes.delete")
});
navigate(`/manage/owners`);
}
} catch (error) {
notification.error({
message: t("owners.errors.deleting", { message: t("owners.errors.deleting", {
error: JSON.stringify(result.errors) error: error.message
}) })
}); });
} finally {
setLoading(false); setLoading(false);
} else {
notification["success"]({
message: t("owners.successes.delete")
});
setLoading(false);
history(`/manage/owners`);
} }
}; };
const handleFinish = async (values) => { const handleFinish = async (values) => {
setLoading(true); setLoading(true);
const result = await updateOwner({ try {
variables: { ownerId: owner.id, owner: values } const result = await updateOwner({
}); variables: { ownerId: owner.id, owner: values }
});
if (!!result.errors) { if (result.errors) {
notification["error"]({ notification.error({
message: t("owners.errors.saving", {
error: JSON.stringify(result.errors)
})
});
} else {
notification.success({
message: t("owners.successes.save")
});
if (refetch) await refetch();
form.resetFields();
}
} catch (error) {
notification.error({
message: t("owners.errors.saving", { message: t("owners.errors.saving", {
error: JSON.stringify(result.errors) error: error.message
}) })
}); });
} finally {
setLoading(false); setLoading(false);
return;
} }
notification["success"]({
message: t("owners.successes.save")
});
if (refetch) await refetch();
form.resetFields();
form.resetFields();
setLoading(false);
}; };
return ( return (
@@ -72,6 +118,7 @@ function OwnerDetailFormContainer({ owner, refetch }) {
title={t("menus.header.owners")} title={t("menus.header.owners")}
extra={[ extra={[
<Popconfirm <Popconfirm
key="delete"
trigger="click" trigger="click"
onConfirm={handleDelete} onConfirm={handleDelete}
disabled={owner.jobs.length !== 0} disabled={owner.jobs.length !== 0}
@@ -81,16 +128,29 @@ function OwnerDetailFormContainer({ owner, refetch }) {
{t("general.actions.delete")} {t("general.actions.delete")}
</Button> </Button>
</Popconfirm>, </Popconfirm>,
<Button type="primary" loading={loading} onClick={() => form.submit()}> <Button key="save" type="primary" loading={loading} onClick={() => form.submit()}>
{t("general.actions.save")} {t("general.actions.save")}
</Button> </Button>
]} ]}
/> />
<Form form={form} onFinish={handleFinish} autoComplete="off" layout="vertical" initialValues={owner}> <Form form={form} onFinish={handleFinish} autoComplete="off" layout="vertical" initialValues={owner}>
<OwnerDetailFormComponent loading={loading} form={form} /> <OwnerDetailFormComponent
loading={loading}
form={form}
isPhone1OptedOut={
bodyshop?.messagingservicesid &&
owner?.ownr_ph1 &&
optedOutPhones.has(phone(owner.ownr_ph1, "CA").phoneNumber?.replace(/^\+1/, ""))
}
isPhone2OptedOut={
bodyshop?.messagingservicesid &&
owner?.ownr_ph2 &&
optedOutPhones.has(phone(owner.ownr_ph2, "CA").phoneNumber?.replace(/^\+1/, ""))
}
/>
</Form> </Form>
</> </>
); );
} }
export default OwnerDetailFormContainer; export default connect(mapStateToProps)(OwnerDetailFormContainer);

View File

@@ -1,12 +1,12 @@
import { useLazyQuery } from "@apollo/client"; import { useLazyQuery } from "@apollo/client";
import { Input, Modal } from "antd"; import { Input, 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 { QUERY_SEARCH_OWNER_BY_IDX } from "../../graphql/owners.queries"; import { QUERY_SEARCH_OWNER_BY_IDX } from "../../graphql/owners.queries";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component"; import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import OwnerFindModalComponent from "./owner-find-modal.component";
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component"; import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import OwnerFindModalComponent from "./owner-find-modal.component";
export default function OwnerFindModalContainer({ export default function OwnerFindModalContainer({
loading, loading,
@@ -41,6 +41,7 @@ export default function OwnerFindModalContainer({
<Modal <Modal
title={<span id="owner-find-modal-title">{t("owners.labels.existing_owners")}</span>} title={<span id="owner-find-modal-title">{t("owners.labels.existing_owners")}</span>}
width={"80%"} width={"80%"}
okButtonProps={{ id: "owner-find-modal-ok-button" }}
{...modalProps} {...modalProps}
> >
{loading ? <LoadingSpinner /> : null} {loading ? <LoadingSpinner /> : null}

View File

@@ -75,7 +75,7 @@ export function PartsOrderBackorderEta({
); );
return ( return (
<Popover destroyTooltipOnHide content={popContent} open={visibility} disabled={disabled}> <Popover destroyOnHidden content={popContent} open={visibility} disabled={disabled}>
<DateFormatter>{backordered_eta}</DateFormatter> <DateFormatter>{backordered_eta}</DateFormatter>
{isAlreadyBackordered && <CalendarFilled style={{ cursor: "pointer" }} onClick={handlePopover} />} {isAlreadyBackordered && <CalendarFilled style={{ cursor: "pointer" }} onClick={handlePopover} />}
{loading && <Spin />} {loading && <Spin />}

View File

@@ -84,7 +84,7 @@ export function PartsOrderLineBackorderButton({ partsOrderStatus, partsLineId, j
); );
return ( return (
<Popover destroyTooltipOnHide content={popContent} open={visibility} disabled={disabled}> <Popover destroyOnHidden content={popContent} open={visibility} disabled={disabled}>
<Button loading={loading} onClick={handlePopover}> <Button loading={loading} onClick={handlePopover}>
{isAlreadyBackordered ? t("parts_orders.actions.receive") : t("parts_orders.actions.backordered")} {isAlreadyBackordered ? t("parts_orders.actions.receive") : t("parts_orders.actions.backordered")}
</Button> </Button>

View File

@@ -333,7 +333,7 @@ export function PartsOrderModalContainer({
onOk={() => form.submit()} onOk={() => form.submit()}
okButtonProps={{ loading: saving }} okButtonProps={{ loading: saving }}
cancelButtonProps={{ loading: saving }} cancelButtonProps={{ loading: saving }}
destroyOnClose destroyOnHidden
width="75%" width="75%"
forceRender forceRender
> >

View File

@@ -46,7 +46,7 @@ export default function PartsQueueDetailCard() {
}; };
return ( return (
<Drawer open={!!selected} destroyOnClose width={drawerPercentage} placement="right" onClose={handleDrawerClose}> <Drawer open={!!selected} destroyOnHidden width={drawerPercentage} placement="right" onClose={handleDrawerClose}>
{loading ? <LoadingSpinner /> : null} {loading ? <LoadingSpinner /> : null}
{error ? <AlertComponent message={error.message} type="error" /> : null} {error ? <AlertComponent message={error.message} type="error" /> : null}
{data ? ( {data ? (

View File

@@ -90,7 +90,7 @@ export function PartsReceiveModalContainer({ partsReceiveModal, toggleModalVisib
onCancel={() => toggleModalVisible()} onCancel={() => toggleModalVisible()}
onOk={() => form.submit()} onOk={() => form.submit()}
okButtonProps={{ loading: loading }} okButtonProps={{ loading: loading }}
destroyOnClose destroyOnHidden
forceRender forceRender
width="50%" width="50%"
> >

View File

@@ -134,7 +134,7 @@ function PaymentModalContainer({ paymentModal, toggleModalVisible, bodyshop }) {
<Modal <Modal
title={!context || (context && !context.id) ? t("payments.labels.new") : t("payments.labels.edit")} title={!context || (context && !context.id) ? t("payments.labels.new") : t("payments.labels.edit")}
open={open} open={open}
destroyOnClose destroyOnHidden
okText={t("general.actions.save")} okText={t("general.actions.save")}
onOk={() => form.submit()} onOk={() => form.submit()}
width="50%" width="50%"

View File

@@ -0,0 +1,146 @@
import { useQuery } from "@apollo/client";
import { Input, Table, Typography } from "antd";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries";
import { TimeAgoFormatter } from "../../utils/DateFormatter";
import ChatOpenButton from "../chat-open-button/chat-open-button.component";
import { useTranslation } from "react-i18next";
import { useState } from "react";
const { Paragraph } = Typography;
// Commented out Associated Owners section for now
//import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
//import { Link } from "react-router-dom";
//import { useMemo, useState } from "react";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
});
const mapDispatchToProps = () => ({});
function PhoneNumberConsentList({ bodyshop, currentUser }) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
// Fetch opt-out phone numbers
const { loading: optOutLoading, data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUTS, {
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined },
fetchPolicy: "network-only"
});
// Commented out Associated Owners section for now
/*// Prepare phone numbers for owner query
const phoneNumbers = useMemo(() => {
return optOutData?.phone_number_opt_out?.map((item) => item.phone_number) || [];
}, [optOutData?.phone_number_opt_out]);
const allPhoneNumbers = useMemo(() => {
const normalized = phoneNumbers;
const withPlusOne = phoneNumbers.map((num) => `+1${num}`);
return [...normalized, ...withPlusOne].filter(Boolean);
}, [phoneNumbers]);
// Fetch owners for all phone numbers
const { loading: ownersLoading, data: ownersData } = useQuery(SEARCH_OWNERS_BY_PHONE_NUMBERS, {
variables: { bodyshopid: bodyshop.id, phone_numbers: allPhoneNumbers },
skip: allPhoneNumbers.length === 0 || !bodyshop.id,
fetchPolicy: "network-only"
});
// Map phone numbers to their associated owners and identify phone field
const getAssociatedOwners = (phoneNumber) => {
if (!ownersData?.owners) return [];
const normalizedPhone = phoneNumber.replace(/^\+1/, "");
return ownersData.owners
.filter(
(owner) =>
owner.ownr_ph1 === phoneNumber ||
owner.ownr_ph2 === phoneNumber ||
owner.ownr_ph1 === normalizedPhone ||
owner.ownr_ph2 === normalizedPhone ||
owner.ownr_ph1 === `+1${phoneNumber}` ||
owner.ownr_ph2 === `+1${phoneNumber}`
)
.map((owner) => ({
...owner,
phoneField:
[owner.ownr_ph1, owner.ownr_ph2].includes(phoneNumber) ||
[owner.ownr_ph1, owner.ownr_ph2].includes(normalizedPhone) ||
[owner.ownr_ph1, owner.ownr_ph2].includes(`+1${phoneNumber}`)
? owner.ownr_ph1 === phoneNumber ||
owner.ownr_ph1 === normalizedPhone ||
owner.ownr_ph1 === `+1${phoneNumber}`
? t("consent.phone_1")
: t("consent.phone_2")
: null
}));
};*/
const columns = [
{
title: t("consent.phone_number"),
dataIndex: "phone_number",
render: (text) => <ChatOpenButton phone={text} />,
sorter: (a, b) => a.phone_number.localeCompare(b.phone_number)
},
// Commented out Associated Owners section for now
/*{
title: t("consent.associated_owners"),
dataIndex: "phone_number",
render: (phoneNumber) => {
const owners = getAssociatedOwners(phoneNumber);
if (!owners || owners.length === 0) {
return t("consent.no_owners");
}
return owners.map((owner) => (
<div key={owner.id}>
<Space direction="horizontal">
<Link to={"/manage/owners/" + owner.id}>
<OwnerNameDisplay ownerObject={owner} />
</Link>
({owner.phoneField})
</Space>
</div>
));
},
sorter: (a, b) => {
const aOwners = getAssociatedOwners(a.phone_number);
const bOwners = getAssociatedOwners(b.phone_number);
const aName = aOwners[0] ? `${aOwners[0].ownr_fn} ${aOwners[0].ownr_ln}` : "";
const bName = bOwners[0] ? `${bOwners[0].ownr_fn} ${bOwners[0].ownr_ln}` : "";
return aName.localeCompare(bName);
}
},*/
{
title: t("consent.created_at"),
dataIndex: "created_at",
render: (text) => <TimeAgoFormatter>{text}</TimeAgoFormatter>,
sorter: (a, b) => new Date(a.created_at) - new Date(b.created_at)
}
];
return (
<div>
<Paragraph>{t("consent.text_body")}</Paragraph>
<Input.Search
placeholder={t("general.labels.search")}
onSearch={(value) => setSearch(value)}
style={{ marginBottom: 16 }}
/>
<Table
columns={columns}
dataSource={optOutData?.phone_number_opt_out}
loading={optOutLoading /* || ownersLoading*/}
rowKey="id"
style={{ marginTop: 16 }}
/>
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(PhoneNumberConsentList);

View File

@@ -32,7 +32,7 @@ export function PrintCenterModalContainer({ printCenterModal, toggleModalVisible
okText={t("general.actions.close")} okText={t("general.actions.close")}
width="90%" width="90%"
title={t("printcenter.labels.title")} title={t("printcenter.labels.title")}
destroyOnClose destroyOnHidden
> >
<PrintCenterModalComponent context={context} /> <PrintCenterModalComponent context={context} />
</Modal> </Modal>

View File

@@ -1,7 +1,15 @@
import { Card, Col, Form, Radio, Row } from "antd"; import { Card, Col, Form, Radio, Row } from "antd";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../../redux/user/user.selectors";
import { HasFeatureAccess } from "../../feature-wrapper/feature-wrapper.component";
const LayoutSettings = ({ t }) => ( const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const LayoutSettings = ({ t, bodyshop }) => (
<Card title={t("production.settings.layout")} style={{ maxWidth: "100%", overflowX: "auto" }}> <Card title={t("production.settings.layout")} style={{ maxWidth: "100%", overflowX: "auto" }}>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{[ {[
@@ -30,14 +38,18 @@ const LayoutSettings = ({ t }) => (
{ value: false, label: t("production.labels.wide") } { value: false, label: t("production.labels.wide") }
] ]
}, },
{ ...(HasFeatureAccess({ bodyshop, featureName: "smartscheduling" })
name: "cardcolor", ? [
label: t("production.labels.cardcolor"), {
options: [ name: "cardcolor",
{ value: true, label: t("production.labels.on") }, label: t("production.labels.cardcolor"),
{ value: false, label: t("production.labels.off") } options: [
] { value: true, label: t("production.labels.on") },
}, { value: false, label: t("production.labels.off") }
]
}
]
: []),
{ {
name: "kiosk", name: "kiosk",
label: t("production.labels.kiosk_mode"), label: t("production.labels.kiosk_mode"),
@@ -67,4 +79,4 @@ LayoutSettings.propTypes = {
t: PropTypes.func.isRequired t: PropTypes.func.isRequired
}; };
export default LayoutSettings; export default connect(mapStateToProps)(LayoutSettings);

View File

@@ -20,7 +20,7 @@ const Board = ({ id, className, orientation, cardSettings, ...additionalProps })
default: default:
return cardSizesVertical.small; return cardSizesVertical.small;
} }
}, [cardSettings]); }, [cardSettings?.cardSize]);
return ( return (
<> <>

View File

@@ -101,11 +101,33 @@ const BoardContainer = ({
async ({ draggableId, type, source, reason, mode, destination, combine }) => { async ({ draggableId, type, source, reason, mode, destination, combine }) => {
setIsDragging(false); setIsDragging(false);
// Only update drag time if it's a valid drop with a different destination // Validate drag type and source
if (type === "lane" && source && destination && !isEqual(source, destination)) { if (type !== "lane" || !source) {
setDragTime(source.droppableId); // Invalid drag type or missing source, attempt to revert if possible
setIsProcessing(true); if (source) {
dispatch(
actions.moveCardAcrossLanes({
fromLaneId: source.droppableId,
toLaneId: source.droppableId,
cardId: draggableId,
index: source.index
})
);
}
setIsProcessing(false);
try {
await onDragEnd({ draggableId, type, source, reason, mode, destination, combine });
} catch (err) {
console.error("Error in onLaneDrag for invalid drag type or source", err);
}
return;
}
setDragTime(source.droppableId);
setIsProcessing(true);
// Handle valid drop to a different lane or position
if (destination && !isEqual(source, destination)) {
dispatch( dispatch(
actions.moveCardAcrossLanes({ actions.moveCardAcrossLanes({
fromLaneId: source.droppableId, fromLaneId: source.droppableId,
@@ -114,14 +136,33 @@ const BoardContainer = ({
index: destination.index index: destination.index
}) })
); );
} else {
// Same-lane drop or no destination, revert to original position
dispatch(
actions.moveCardAcrossLanes({
fromLaneId: source.droppableId,
toLaneId: source.droppableId,
cardId: draggableId,
index: source.index
})
);
}
try { try {
await onDragEnd({ draggableId, type, source, reason, mode, destination, combine }); await onDragEnd({ draggableId, type, source, reason, mode, destination, combine });
} catch (err) { } catch (err) {
console.error("Error in onLaneDrag", err); console.error("Error in onLaneDrag", err);
} finally { // Ensure revert on error
setIsProcessing(false); dispatch(
} actions.moveCardAcrossLanes({
fromLaneId: source.droppableId,
toLaneId: source.droppableId,
cardId: draggableId,
index: source.index
})
);
} finally {
setIsProcessing(false);
} }
}, },
[dispatch, onDragEnd, setDragTime] [dispatch, onDragEnd, setDragTime]

View File

@@ -133,7 +133,9 @@ const Lane = ({
Item: ItemComponent Item: ItemComponent
}, },
itemContent: (index, item) => <ItemWrapper>{renderDraggable(index, item)}</ItemWrapper>, itemContent: (index, item) => <ItemWrapper>{renderDraggable(index, item)}</ItemWrapper>,
overscan: { main: 10, reverse: 10 } overscan: { main: 10, reverse: 10 },
// Ensure a minimum height for empty lanes to allow dropping
style: renderedCards.length === 0 ? { minHeight: "5px" } : {}
}; };
const horizontalProps = { const horizontalProps = {
@@ -149,8 +151,6 @@ const Lane = ({
const componentProps = orientation === "vertical" ? verticalProps : horizontalProps; const componentProps = orientation === "vertical" ? verticalProps : horizontalProps;
// If the lane is collapsed, we want to render a div instead of the virtualized list, and we want to set the height to the max height of the lane so that
// the lane doesn't shrink when collapsed (in horizontal mode)
const finalComponentProps = collapsed const finalComponentProps = collapsed
? orientation === "horizontal" ? orientation === "horizontal"
? { ? {
@@ -161,9 +161,8 @@ const Lane = ({
: {} : {}
: componentProps; : componentProps;
// If the lane is horizontal and collapsed, we want to render a placeholder so that the lane doesn't shrink to 0 height and grows when // Always render placeholder for empty lanes in vertical mode to ensure droppable area
// a card is dragged over it const shouldRenderPlaceholder = orientation === "vertical" ? collapsed || renderedCards.length === 0 : collapsed;
const shouldRenderPlaceholder = orientation !== "horizontal" && (collapsed || renderedCards.length === 0);
return ( return (
<HeightMemoryWrapper <HeightMemoryWrapper
@@ -178,8 +177,8 @@ const Lane = ({
override={orientation !== "horizontal" && (collapsed || !renderedCards.length)} override={orientation !== "horizontal" && (collapsed || !renderedCards.length)}
> >
<div <div
ref={laneRef} // Ensure laneRef is set here ref={laneRef}
style={{ height: "100%", width: "100%" }} // Make it scrollable style={{ height: "100%", width: "100%" }}
className={`react-trello-lane ${collapsed ? "lane-collapsed" : ""}`} className={`react-trello-lane ${collapsed ? "lane-collapsed" : ""}`}
> >
<div {...provided.droppableProps} ref={provided.innerRef} style={{ ...provided.droppableProps.style }}> <div {...provided.droppableProps} ref={provided.innerRef} style={{ ...provided.droppableProps.style }}>

View File

@@ -140,7 +140,7 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
if (record[type]) theEmployee = bodyshop.employees.find((e) => e.id === record[type]); if (record[type]) theEmployee = bodyshop.employees.find((e) => e.id === record[type]);
return ( return (
<Popover destroyTooltipOnHide content={popContent} open={visibility}> <Popover destroyOnHidden content={popContent} open={visibility}>
<Spin spinning={loading}> <Spin spinning={loading}>
{record[type] ? ( {record[type] ? (
<div> <div>

View File

@@ -6,6 +6,7 @@ import React, { 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 { QUERY_ACTIVE_EMPLOYEES, QUERY_ACTIVE_EMPLOYEES_WITH_EMAIL } from "../../graphql/employees.queries"; import { QUERY_ACTIVE_EMPLOYEES, QUERY_ACTIVE_EMPLOYEES_WITH_EMAIL } from "../../graphql/employees.queries";
import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries"; import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries";
import { selectReportCenter } from "../../redux/modals/modals.selectors"; import { selectReportCenter } from "../../redux/modals/modals.selectors";
@@ -18,11 +19,10 @@ import EmployeeSearchSelectEmail from "../employee-search-select/employee-search
import EmployeeSearchSelect from "../employee-search-select/employee-search-select.component"; import EmployeeSearchSelect from "../employee-search-select/employee-search-select.component";
import BlurWrapperComponent from "../feature-wrapper/blur-wrapper.component"; import BlurWrapperComponent from "../feature-wrapper/blur-wrapper.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component"; import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
import ReportCenterModalFiltersSortersComponent from "./report-center-modal-filters-sorters-component"; import ReportCenterModalFiltersSortersComponent from "./report-center-modal-filters-sorters-component";
import "./report-center-modal.styles.scss"; import "./report-center-modal.styles.scss";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
reportCenterModal: selectReportCenter, reportCenterModal: selectReportCenter,
@@ -389,5 +389,7 @@ const restrictedReports = [
{ key: "job_costing_ro_date_detail", days: 183 }, { key: "job_costing_ro_date_detail", days: 183 },
{ key: "job_costing_ro_estimator", days: 183 }, { key: "job_costing_ro_estimator", days: 183 },
{ key: "job_lifecycle_date_detail", days: 183 }, { key: "job_lifecycle_date_detail", days: 183 },
{ key: "job_lifecycle_date_summary", days: 183 } { key: "job_lifecycle_date_summary", days: 183 },
{ key: "customer_list", days: 183 },
{ key: "customer_list_excel", days: 183 }
]; ];

View File

@@ -28,7 +28,7 @@ export function ReportCenterModalContainer({ reportCenterModal, toggleModalVisib
onOk={() => toggleModalVisible()} onOk={() => toggleModalVisible()}
onCancel={() => toggleModalVisible()} onCancel={() => toggleModalVisible()}
cancelButtonProps={{ style: { display: "none" } }} cancelButtonProps={{ style: { display: "none" } }}
destroyOnClose destroyOnHidden
width="80%" width="80%"
> >
<RbacWrapperComponent action="shop:reportcenter"> <RbacWrapperComponent action="shop:reportcenter">

View File

@@ -209,7 +209,7 @@ export function ScheduleJobModalContainer({
onOk={() => form.submit()} onOk={() => form.submit()}
width={"90%"} width={"90%"}
maskClosable={false} maskClosable={false}
destroyOnClose destroyOnHidden
okButtonProps={{ okButtonProps={{
loading: loading loading: loading
}} }}

View File

@@ -106,7 +106,7 @@ export default function ScoreboardJobsList({ scoreBoardlist }) {
<> <>
<Modal <Modal
open={state.open} open={state.open}
destroyOnClose destroyOnHidden
width="80%" width="80%"
closable={false} closable={false}
cancelButtonProps={{ style: { display: "none" } }} cancelButtonProps={{ style: { display: "none" } }}

View File

@@ -1,35 +1,34 @@
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Card, Tabs } from "antd"; import { Button, Card, Tabs } from "antd";
import React from "react"; import queryString from "query-string";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import ShopInfoGeneral from "./shop-info.general.component"; import ShopInfoGeneral from "./shop-info.general.component";
import ShopInfoIntakeChecklistComponent from "./shop-info.intake.component"; import ShopInfoIntakeChecklistComponent from "./shop-info.intake.component";
import ShopInfoLaborRates from "./shop-info.laborrates.component"; import ShopInfoLaborRates from "./shop-info.laborrates.component";
import ShopInfoNotificationsAutoadd from "./shop-info.notifications-autoadd.component.jsx";
import ShopInfoOrderStatusComponent from "./shop-info.orderstatus.component"; import ShopInfoOrderStatusComponent from "./shop-info.orderstatus.component";
import ShopInfoPartsScan from "./shop-info.parts-scan"; import ShopInfoPartsScan from "./shop-info.parts-scan";
import ShopInfoRbacComponent from "./shop-info.rbac.component"; import ShopInfoRbacComponent from "./shop-info.rbac.component";
import ShopInfoResponsibilityCenterComponent from "./shop-info.responsibilitycenters.component"; import ShopInfoResponsibilityCenterComponent from "./shop-info.responsibilitycenters.component";
import ShopInfoRoGuard from "./shop-info.roguard.component";
import ShopInfoROStatusComponent from "./shop-info.rostatus.component"; import ShopInfoROStatusComponent from "./shop-info.rostatus.component";
import ShopInfoSchedulingComponent from "./shop-info.scheduling.component"; import ShopInfoSchedulingComponent from "./shop-info.scheduling.component";
import ShopInfoSpeedPrint from "./shop-info.speedprint.component"; import ShopInfoSpeedPrint from "./shop-info.speedprint.component";
import { useLocation, useNavigate } from "react-router-dom";
import ShopInfoTaskPresets from "./shop-info.task-presets.component"; import ShopInfoTaskPresets from "./shop-info.task-presets.component";
import queryString from "query-string";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import ShopInfoRoGuard from "./shop-info.roguard.component";
import ShopInfoIntellipay from "./shop-intellipay-config.component"; import ShopInfoIntellipay from "./shop-intellipay-config.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import ShopInfoNotificationsAutoadd from "./shop-info.notifications-autoadd.component.jsx";
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))
}); });
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoComponent); export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoComponent);
@@ -158,7 +157,7 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
return ( return (
<Card <Card
extra={ extra={
<Button type="primary" loading={saveLoading} onClick={() => form.submit()}> <Button type="primary" loading={saveLoading} onClick={() => form.submit()} id="shop-info-save-button">
{t("general.actions.save")} {t("general.actions.save")}
</Button> </Button>
} }

View File

@@ -0,0 +1,25 @@
import { Typography } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import PhoneNumberConsentList from "../phone-number-consent/phone-number-consent.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({});
function ShopInfoConsentComponent({ bodyshop }) {
const { t } = useTranslation();
return (
<div>
<Typography.Title level={4}>{t("settings.title")}</Typography.Title>
{<PhoneNumberConsentList bodyshop={bodyshop} />}
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoConsentComponent);

View File

@@ -1,7 +1,6 @@
import { DeleteFilled } from "@ant-design/icons"; import { DeleteFilled } from "@ant-design/icons";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, DatePicker, Form, Input, InputNumber, Radio, Select, Space, Switch } from "antd"; import { Button, DatePicker, Form, Input, InputNumber, Radio, Select, Space, Switch } from "antd";
import React 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";
@@ -19,7 +18,7 @@ const timeZonesList = Intl.supportedValuesOf("timeZone");
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))
}); });
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoGeneral); export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoGeneral);
@@ -823,7 +822,7 @@ export function ShopInfoGeneral({ form, bodyshop }) {
}} }}
</Form.List> </Form.List>
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow grow header={t("bodyshop.labels.insurancecos")} id="insurancecos"> <LayoutFormRow grow header=<span id="insurancecos-header">{t("bodyshop.labels.insurancecos")}</span> id="insurancecos">
<Form.List name={["md_ins_cos"]}> <Form.List name={["md_ins_cos"]}>
{(fields, { add, remove, move }) => { {(fields, { add, remove, move }) => {
return ( return (

View File

@@ -8,7 +8,7 @@ export default function ShopInfoNotificationsAutoadd({ bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
// Filter employee options to ensure active employees with valid IDs // Filter employee options to ensure active employees with valid IDs
const employeeOptions = bodyshop?.employees?.filter((e) => e.active && e.id && typeof e.id === "string") || []; const employeeOptions = bodyshop?.employees?.filter((e) => e.active && e.user_email && e.id) || [];
return ( return (
<div> <div>

View File

@@ -16,5 +16,6 @@ export default connect(mapStateToProps, mapDispatchToProps)(ShopSubStatus);
export function ShopSubStatus({ bodyshop }) { export function ShopSubStatus({ bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { sub_status } = bodyshop; const { sub_status } = bodyshop;
return <Result status="403" title={t(`general.labels.sub_status.${sub_status}`)} />; // expired trail-expired' are the valid sub_status values
return <Result status="403" title={t(`general.errors.sub_status.${sub_status}`)} />;
} }

View File

@@ -275,7 +275,7 @@ export function TaskUpsertModalContainer({ bodyshop, currentUser, taskUpsert, to
toggleModalVisible(); toggleModalVisible();
}} }}
okButtonProps={{ disabled: !isTouched }} okButtonProps={{ disabled: !isTouched }}
destroyOnClose destroyOnHidden
> >
<Form <Form
form={form} form={form}

View File

@@ -70,7 +70,7 @@ export function TechLookupJobsDrawer({ bodyshop, setPrintCenterContext }) {
}; };
return ( return (
<Drawer open={!!selected} destroyOnClose width={drawerPercentage} placement="right" onClose={handleDrawerClose}> <Drawer open={!!selected} destroyOnHidden width={drawerPercentage} placement="right" onClose={handleDrawerClose}>
{loading ? <LoadingSpinner /> : null} {loading ? <LoadingSpinner /> : null}
{error ? <AlertComponent message={error.message} type="error" /> : null} {error ? <AlertComponent message={error.message} type="error" /> : null}
{data ? ( {data ? (

View File

@@ -107,7 +107,7 @@ export default function TimeTicketCalculatorComponent({
open={visible} open={visible}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
placement="right" placement="right"
destroyTooltipOnHide destroyOnHidden
> >
<Button onClick={(e) => e.preventDefault()}> <Button onClick={(e) => e.preventDefault()}>
<Space> <Space>

View File

@@ -39,7 +39,7 @@ export function TimeTicketListTeamPay({ bodyshop, context, actions }) {
return ( return (
<> <>
<Modal width={"80%"} open={visible} destroyOnClose onOk={handleOk} onCancel={() => setVisible(false)}> <Modal width={"80%"} open={visible} destroyOnHidden onOk={handleOk} onCancel={() => setVisible(false)}>
<Form layout="vertical" form={form} initialValues={{ jobid: jobId }}> <Form layout="vertical" form={form} initialValues={{ jobid: jobId }}>
<LayoutFormRow grow noDivider> <LayoutFormRow grow noDivider>
<Form.Item shouldUpdate> <Form.Item shouldUpdate>

View File

@@ -181,7 +181,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
)} )}
</Space> </Space>
} }
destroyOnClose destroyOnHidden
id="time-ticket-modal" id="time-ticket-modal"
> >
<Form <Form

View File

@@ -119,7 +119,7 @@ export function TimeTickeTaskModalContainer({
return ( return (
<Modal <Modal
destroyOnClose destroyOnHidden
open={open} open={open}
onCancel={() => { onCancel={() => {
toggleModalVisible(); toggleModalVisible();

View File

@@ -312,7 +312,6 @@ export const QUERY_INTAKE_CHECKLIST = gql`
intakechecklist intakechecklist
status status
owner { owner {
allow_text_message
id id
} }
labhrs: joblines_aggregate(where: { _and: [{ mod_lbr_ty: { _neq: "LAR" } }, { removed: { _eq: false } }] }) { labhrs: joblines_aggregate(where: { _and: [{ mod_lbr_ty: { _neq: "LAR" } }, { removed: { _eq: false } }] }) {

View File

@@ -43,6 +43,7 @@ export const CONVERSATION_SUBSCRIPTION_BY_PK = gql`
id id
status status
text text
is_system
isoutbound isoutbound
image image
image_path image_path
@@ -77,6 +78,7 @@ export const GET_CONVERSATION_DETAILS = gql`
id id
status status
text text
is_system
isoutbound isoutbound
image image
image_path image_path

View File

@@ -685,6 +685,8 @@ export const GET_JOB_BY_PK = gql`
scheduled_delivery scheduled_delivery
scheduled_in scheduled_in
selling_dealer selling_dealer
estimate_approved
estimate_sent_approval
selling_dealer_contact selling_dealer_contact
servicing_dealer servicing_dealer
servicing_dealer_contact servicing_dealer_contact
@@ -872,7 +874,6 @@ export const QUERY_JOB_CARD_DETAILS = gql`
} }
owner { owner {
id id
allow_text_message
preferred_contact preferred_contact
tax_number tax_number
} }
@@ -929,6 +930,8 @@ export const QUERY_JOB_CARD_DETAILS = gql`
date_exported date_exported
date_repairstarted date_repairstarted
date_scheduled date_scheduled
estimate_sent_approval
estimate_approved
date_estimated date_estimated
employee_body_rel { employee_body_rel {
id id
@@ -1077,6 +1080,8 @@ export const UPDATE_JOB = gql`
date_repairstarted date_repairstarted
date_void date_void
date_lost_sale date_lost_sale
estimate_sent_approval
estimate_approved
} }
} }
} }
@@ -2065,7 +2070,6 @@ export const QUERY_JOB_CHECKLISTS = gql`
production_vars production_vars
owner { owner {
id id
allow_text_message
} }
bodyshop { bodyshop {
id id
@@ -2422,7 +2426,6 @@ export const QUERY_PARTS_QUEUE_CARD_DETAILS = gql`
ownr_ph2 ownr_ph2
owner { owner {
id id
allow_text_message
preferred_contact preferred_contact
tax_number tax_number
} }
@@ -2431,6 +2434,8 @@ export const QUERY_PARTS_QUEUE_CARD_DETAILS = gql`
plate_st plate_st
po_number po_number
production_vars production_vars
estimate_sent_approval
estimate_approved
ro_number ro_number
scheduled_completion scheduled_completion
scheduled_delivery scheduled_delivery

View File

@@ -49,7 +49,6 @@ export const QUERY_OWNER_BY_ID = gql`
owners_by_pk(id: $id) { owners_by_pk(id: $id) {
id id
accountingid accountingid
allow_text_message
ownr_addr1 ownr_addr1
ownr_addr2 ownr_addr2
ownr_co_nm ownr_co_nm
@@ -104,7 +103,6 @@ export const QUERY_ALL_OWNERS = gql`
query QUERY_ALL_OWNERS { query QUERY_ALL_OWNERS {
owners { owners {
id id
allow_text_message
created_at created_at
ownr_addr1 ownr_addr1
ownr_addr2 ownr_addr2
@@ -129,7 +127,6 @@ export const QUERY_ALL_OWNERS_PAGINATED = gql`
query QUERY_ALL_OWNERS_PAGINATED($search: String, $offset: Int, $limit: Int, $order: [owners_order_by!]!) { query QUERY_ALL_OWNERS_PAGINATED($search: String, $offset: Int, $limit: Int, $order: [owners_order_by!]!) {
search_owners(args: { search: $search }, offset: $offset, limit: $limit, order_by: $order) { search_owners(args: { search: $search }, offset: $offset, limit: $limit, order_by: $order) {
id id
allow_text_message
created_at created_at
ownr_addr1 ownr_addr1
ownr_addr2 ownr_addr2

View File

@@ -0,0 +1,64 @@
import { gql } from "@apollo/client";
export const GET_PHONE_NUMBER_OPT_OUT = gql`
query GET_PHONE_NUMBER_OPT_OUT($bodyshopid: uuid!, $phone_number: String!) {
phone_number_opt_out(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _eq: $phone_number } }) {
id
bodyshopid
phone_number
created_at
updated_at
}
}
`;
export const GET_PHONE_NUMBER_OPT_OUTS = gql`
query GET_PHONE_NUMBER_OPT_OUTS($bodyshopid: uuid!, $search: String) {
phone_number_opt_out(
where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _ilike: $search } }
order_by: [{ phone_number: asc }, { updated_at: desc }]
) {
id
bodyshopid
phone_number
created_at
updated_at
}
}
`;
export const GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS = gql`
query GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS($bodyshopid: uuid!, $phone_numbers: [String!]) {
phone_number_opt_out(
where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _in: $phone_numbers } }
) {
id
bodyshopid
phone_number
created_at
updated_at
}
}
`;
export const SEARCH_OWNERS_BY_PHONE_NUMBERS = gql`
query SEARCH_OWNERS_BY_PHONE_NUMBERS($bodyshopid: uuid!, $phone_numbers: [String!]) {
owners(
where: {
shopid: { _eq: $bodyshopid },
_or: [
{ ownr_ph1: { _in: $phone_numbers } },
{ ownr_ph2: { _in: $phone_numbers } }
]
}
) {
id
ownr_fn
ownr_ln
ownr_co_nm
ownr_ph1
ownr_ph2
__typename
}
}
`;

View File

@@ -114,7 +114,6 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
if (!!!job.ownerid) { if (!!!job.ownerid) {
ownerData = job.owner.data; ownerData = job.owner.data;
ownerData.shopid = bodyshop.id; ownerData.shopid = bodyshop.id;
delete ownerData.allow_text_message;
delete ownerData.preferred_contact; delete ownerData.preferred_contact;
delete job.ownerid; delete job.ownerid;
} else { } else {

View File

@@ -14,7 +14,7 @@ import { Badge, Button, Divider, Form, Space, Tabs } 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 { FaHardHat, FaRegStickyNote, FaShieldAlt, FaTasks } from "react-icons/fa"; import { FaHardHat, FaRegStickyNote, FaShieldAlt, FaTasks } from "react-icons/fa";
import { connect } from "react-redux"; import { connect } from "react-redux";
@@ -28,6 +28,7 @@ import JobLineUpsertModalContainer from "../../components/job-lines-upsert-modal
import JobProfileDataWarning from "../../components/job-profile-data-warning/job-profile-data-warning.component"; import JobProfileDataWarning from "../../components/job-profile-data-warning/job-profile-data-warning.component";
import JobReconciliationModal from "../../components/job-reconciliation-modal/job-reconciliation.modal.container"; import JobReconciliationModal from "../../components/job-reconciliation-modal/job-reconciliation.modal.container";
import JobSyncButton from "../../components/job-sync-button/job-sync-button.component"; import JobSyncButton from "../../components/job-sync-button/job-sync-button.component";
import JobWatcherToggleContainer from "../../components/job-watcher-toggle/job-watcher-toggle.container.jsx";
import JobsChangeStatus from "../../components/jobs-change-status/jobs-change-status.component"; import JobsChangeStatus from "../../components/jobs-change-status/jobs-change-status.component";
import JobsConvertButton from "../../components/jobs-convert-button/jobs-convert-button.component"; import JobsConvertButton from "../../components/jobs-convert-button/jobs-convert-button.component";
import JobsDetailDatesComponent from "../../components/jobs-detail-dates/jobs-detail-dates.component"; import JobsDetailDatesComponent from "../../components/jobs-detail-dates/jobs-detail-dates.component";
@@ -45,6 +46,8 @@ import LockWrapperComponent from "../../components/lock-wrapper/lock-wrapper.com
import NoteUpsertModalComponent from "../../components/note-upsert-modal/note-upsert-modal.container"; import NoteUpsertModalComponent from "../../components/note-upsert-modal/note-upsert-modal.container";
import ScheduleJobModalContainer from "../../components/schedule-job-modal/schedule-job-modal.container"; import ScheduleJobModalContainer from "../../components/schedule-job-modal/schedule-job-modal.container";
import TaskListContainer from "../../components/task-list/task-list.container.jsx"; import TaskListContainer from "../../components/task-list/task-list.container.jsx";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { QUERY_PARTS_BILLS_BY_JOBID } from "../../graphql/bills.queries.js"; import { QUERY_PARTS_BILLS_BY_JOBID } from "../../graphql/bills.queries.js";
import { QUERY_JOB_TASKS_PAGINATED } from "../../graphql/tasks.queries.js"; import { QUERY_JOB_TASKS_PAGINATED } from "../../graphql/tasks.queries.js";
import { insertAuditTrail } from "../../redux/application/application.actions"; import { insertAuditTrail } from "../../redux/application/application.actions";
@@ -55,9 +58,6 @@ import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { DateTimeFormat } from "../../utils/DateFormatter"; import { DateTimeFormat } from "../../utils/DateFormatter";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import UndefinedToNull from "../../utils/undefinedtonull"; import UndefinedToNull from "../../utils/undefinedtonull";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import JobWatcherToggleContainer from "../../components/job-watcher-toggle/job-watcher-toggle.container.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -322,11 +322,11 @@ export function JobsDetailPage({
> >
<PageHeader <PageHeader
// onBack={() => window.history.back()} // onBack={() => window.history.back()}
id="job-detail-header"
title={ title={
<Space> <Space>
{scenarioNotificationsOn && <JobWatcherToggleContainer job={job} />} {scenarioNotificationsOn && <JobWatcherToggleContainer job={job} />}
{job.ro_number || t("general.labels.na")} <span id="job-ro_number">{job.ro_number || t("general.labels.na")}</span>
</Space> </Space>
} }
extra={menuExtra} extra={menuExtra}

View File

@@ -10,10 +10,10 @@ import ShopCsiConfig from "../../components/shop-csi-config/shop-csi-config.comp
import ShopEmployeesContainer from "../../components/shop-employees/shop-employees.container"; import ShopEmployeesContainer from "../../components/shop-employees/shop-employees.container";
import ShopInfoContainer from "../../components/shop-info/shop-info.container"; import ShopInfoContainer from "../../components/shop-info/shop-info.container";
import ShopInfoUsersComponent from "../../components/shop-users/shop-users.component"; import ShopInfoUsersComponent from "../../components/shop-users/shop-users.component";
import ShopInfoConsentComponent from "../../components/shop-info/shop-info.consent.component";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr"; import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { HasFeatureAccess } from "../../components/feature-wrapper/feature-wrapper.component"; import { HasFeatureAccess } from "../../components/feature-wrapper/feature-wrapper.component";
import ShopTeamsContainer from "../../components/shop-teams/shop-teams.container"; import ShopTeamsContainer from "../../components/shop-teams/shop-teams.container";
@@ -91,6 +91,16 @@ export function ShopPage({ bodyshop, setSelectedHeader, setBreadcrumbs }) {
children: <ShopCsiConfig /> children: <ShopCsiConfig />
}); });
} }
if (bodyshop.messagingservicesid) {
// Add Consent Settings tab
items.push({
key: "consent",
label: t("bodyshop.labels.consent_settings"),
children: <ShopInfoConsentComponent bodyshop={bodyshop} />
});
}
return ( return (
<RbacWrapper action="shop:config"> <RbacWrapper action="shop:config">
<Tabs activeKey={search.tab} onChange={(key) => history({ search: `?tab=${key}` })} items={items} /> <Tabs activeKey={search.tab} onChange={(key) => history({ search: `?tab=${key}` })} items={items} />

View File

@@ -105,7 +105,6 @@ const userReducer = (state = INITIAL_STATE, action) => {
...action.payload //Spread current user details in. ...action.payload //Spread current user details in.
} }
}; };
case UserActionTypes.SET_SHOP_DETAILS: case UserActionTypes.SET_SHOP_DETAILS:
return { return {
...state, ...state,
@@ -126,6 +125,7 @@ const userReducer = (state = INITIAL_STATE, action) => {
...state, ...state,
imexshopid: action.payload imexshopid: action.payload
}; };
default: default:
return state; return state;
} }

View File

@@ -335,20 +335,12 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
} }
try { try {
InstanceRenderManager({ window.$crisp.push(["set", "user:company", [payload.shopname]]);
executeFunction: true, window.$crisp.push(["set", "session:segments", [[`region:${payload.region_config}`]]]);
args: [], if (authRecord[0] && authRecord[0].user.validemail) {
imex: () => { window.$crisp.push(["set", "user:email", [authRecord[0].user.email]]);
window.$crisp.push(["set", "user:company", [payload.shopname]]); }
window.$crisp.push(["set", "session:segments", [[`region:${payload.region_config}`]]]);
if (authRecord[0] && authRecord[0].user.validemail) {
window.$crisp.push(["set", "user:email", [authRecord[0].user.email]]);
}
},
rome: () => {
window.$zoho.salesiq.visitor.info({ "Shop Name": payload.shopname });
}
});
payload.features?.allAccess === true payload.features?.allAccess === true
? window.$crisp.push(["set", "session:segments", [["allAccess"]]]) ? window.$crisp.push(["set", "session:segments", [["allAccess"]]])
: (() => { : (() => {
@@ -359,6 +351,14 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
); );
window.$crisp.push(["set", "session:segments", [["basic", ...featureKeys]]]); window.$crisp.push(["set", "session:segments", [["basic", ...featureKeys]]]);
})(); })();
InstanceRenderManager({
executeFunction: true,
args: [],
rome: () => {
window.$zoho.salesiq.visitor.info({ "Shop Name": payload.shopname });
}
});
} catch (error) { } catch (error) {
console.warn("Couldnt find $crisp.", error.message); console.warn("Couldnt find $crisp.", error.message);
} }

View File

@@ -656,6 +656,7 @@
} }
}, },
"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",
@@ -774,7 +775,6 @@
}, },
"labels": { "labels": {
"addtoproduction": "Add Job to Production?", "addtoproduction": "Add Job to Production?",
"allow_text_message": "Permission to Text?",
"checklist": "Checklist", "checklist": "Checklist",
"printpack": "Job Intake Print Pack", "printpack": "Job Intake Print Pack",
"removefromproduction": "Remove Job from Production?" "removefromproduction": "Remove Job from Production?"
@@ -1230,7 +1230,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." "submit-for-testing": "Error submitting Job for testing.",
"sub_status": {
"expired": "The subscription for this shop has expired. Please contact Sales to reactivate.",
"trial-expired": "The trial for this shop has expired. Please contact Sales to reactivate."
}
}, },
"itemtypes": { "itemtypes": {
"contract": "CC Contract", "contract": "CC Contract",
@@ -1650,6 +1654,8 @@
"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",
@@ -1955,6 +1961,8 @@
"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).",
@@ -2027,9 +2035,10 @@
"stands": "Stands", "stands": "Stands",
"waived": "Waived" "waived": "Waived"
}, },
"deleteconfirm": "Are you sure you want to delete this Job? This cannot be undone. ", "deleteconfirm": "Are you sure you want to delete this Job? This cannot be undone.",
"deletedelivery": "Delete Delivery Checklist", "deletedelivery": "Delete Delivery Checklist",
"deleteintake": "Delete Intake Checklist", "deleteintake": "Delete Intake Checklist",
"deletewatchers": "Remove Watchers before deleting this Job.",
"deliverchecklist": "Deliver Checklist", "deliverchecklist": "Deliver Checklist",
"difference": "Difference", "difference": "Difference",
"diskscan": "Scan Disk for Estimates", "diskscan": "Scan Disk for Estimates",
@@ -2297,8 +2306,10 @@
"productionlist": "Production Board - List", "productionlist": "Production Board - List",
"readyjobs": "Ready Jobs", "readyjobs": "Ready Jobs",
"recent": "Recent Items", "recent": "Recent Items",
"remoteassist": "Remote Assist",
"reportcenter": "Report Center", "reportcenter": "Report Center",
"rescueme": "Rescue me!", "rescueme": "Rescue Me!",
"rescuemezoho": "Remote Me In!",
"schedule": "Schedule", "schedule": "Schedule",
"scoreboard": "Scoreboard", "scoreboard": "Scoreboard",
"search": { "search": {
@@ -2373,7 +2384,8 @@
"errors": { "errors": {
"invalidphone": "The phone number is invalid. Unable to open conversation. ", "invalidphone": "The phone number is invalid. Unable to open conversation. ",
"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.",
@@ -2389,7 +2401,8 @@
"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"
@@ -2470,7 +2483,8 @@
"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",
@@ -2490,7 +2504,9 @@
"tasks-updated-created": "Tasks Updated / Created" "tasks-updated-created": "Tasks Updated / Created"
}, },
"tooltips": { "tooltips": {
"job-watchers": "Job Watchers" "job-watchers": "Job Watchers",
"not-employee": "You need to be an employee to watch this job. Reach out to your admin to get set up!",
"not-employee-notifications": "You must be an employee to receive notifications"
} }
}, },
"owner": { "owner": {
@@ -2511,7 +2527,6 @@
"fields": { "fields": {
"accountingid": "Accounting ID", "accountingid": "Accounting ID",
"address": "Address", "address": "Address",
"allow_text_message": "Permission to Text?",
"name": "Name", "name": "Name",
"note": "Owner Note", "note": "Owner Note",
"ownr_addr1": "Address", "ownr_addr1": "Address",
@@ -3092,6 +3107,7 @@
"credits_not_received_date_vendorid": "Credits not Received by Vendor", "credits_not_received_date_vendorid": "Credits not Received by Vendor",
"csi": "CSI Responses", "csi": "CSI Responses",
"customer_list": "Customer List", "customer_list": "Customer List",
"customer_list_excel": "Customer List - Excel",
"cycle_time_analysis": "Cycle Time Analysis", "cycle_time_analysis": "Cycle Time Analysis",
"estimates_written_converted": "Estimates Written/Converted", "estimates_written_converted": "Estimates Written/Converted",
"estimator_detail": "Jobs by Estimator (Detail)", "estimator_detail": "Jobs by Estimator (Detail)",
@@ -3855,6 +3871,18 @@
"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

@@ -656,6 +656,7 @@
} }
}, },
"labels": { "labels": {
"consent_settings": "",
"2tiername": "", "2tiername": "",
"2tiersetup": "", "2tiersetup": "",
"2tiersource": "", "2tiersource": "",
@@ -774,7 +775,6 @@
}, },
"labels": { "labels": {
"addtoproduction": "", "addtoproduction": "",
"allow_text_message": "",
"checklist": "", "checklist": "",
"printpack": "", "printpack": "",
"removefromproduction": "" "removefromproduction": ""
@@ -1230,7 +1230,11 @@
"fcm": "", "fcm": "",
"notfound": "", "notfound": "",
"sizelimit": "", "sizelimit": "",
"submit-for-testing": "" "submit-for-testing": "",
"sub_status": {
"expired": "",
"trial-expired": ""
}
}, },
"itemtypes": { "itemtypes": {
"contract": "", "contract": "",
@@ -1642,6 +1646,8 @@
"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",
@@ -1955,6 +1961,8 @@
"scheddates": "" "scheddates": ""
}, },
"labels": { "labels": {
"sent": "",
"approved": "",
"accountsreceivable": "", "accountsreceivable": "",
"act_price_ppc": "", "act_price_ppc": "",
"actual_completion_inferred": "", "actual_completion_inferred": "",
@@ -2030,6 +2038,7 @@
"deleteconfirm": "", "deleteconfirm": "",
"deletedelivery": "", "deletedelivery": "",
"deleteintake": "", "deleteintake": "",
"deletewatchers": "",
"deliverchecklist": "", "deliverchecklist": "",
"difference": "", "difference": "",
"diskscan": "", "diskscan": "",
@@ -2297,8 +2306,10 @@
"productionlist": "", "productionlist": "",
"readyjobs": "", "readyjobs": "",
"recent": "", "recent": "",
"remoteassist": "",
"reportcenter": "", "reportcenter": "",
"rescueme": "", "rescueme": "",
"rescuemezoho": "",
"schedule": "Programar", "schedule": "Programar",
"scoreboard": "", "scoreboard": "",
"search": { "search": {
@@ -2373,7 +2384,8 @@
"errors": { "errors": {
"invalidphone": "", "invalidphone": "",
"noattachedjobs": "", "noattachedjobs": "",
"updatinglabel": "" "updatinglabel": "",
"no_consent": ""
}, },
"labels": { "labels": {
"addlabel": "", "addlabel": "",
@@ -2389,7 +2401,8 @@
"selectmedia": "", "selectmedia": "",
"sentby": "", "sentby": "",
"typeamessage": "Enviar un mensaje...", "typeamessage": "Enviar un mensaje...",
"unarchive": "" "unarchive": "",
"no_consent": ""
}, },
"render": { "render": {
"conversation_list": "" "conversation_list": ""
@@ -2472,7 +2485,8 @@
"teams-search": "", "teams-search": "",
"unwatch": "", "unwatch": "",
"watch": "", "watch": "",
"watching-issue": "" "watching-issue": "",
"employee-notification": ""
}, },
"scenarios": { "scenarios": {
"alternate-transport-changed": "", "alternate-transport-changed": "",
@@ -2492,7 +2506,9 @@
"tasks-updated-created": "" "tasks-updated-created": ""
}, },
"tooltips": { "tooltips": {
"job-watchers": "" "job-watchers": "",
"not-employee": "",
"not-employee-notifications": ""
} }
}, },
"owner": { "owner": {
@@ -2513,7 +2529,6 @@
"fields": { "fields": {
"accountingid": "", "accountingid": "",
"address": "Dirección", "address": "Dirección",
"allow_text_message": "Permiso de texto?",
"name": "Nombre", "name": "Nombre",
"note": "", "note": "",
"ownr_addr1": "Dirección", "ownr_addr1": "Dirección",
@@ -3094,6 +3109,7 @@
"credits_not_received_date_vendorid": "", "credits_not_received_date_vendorid": "",
"csi": "", "csi": "",
"customer_list": "", "customer_list": "",
"customer_list_excel": "",
"cycle_time_analysis": "", "cycle_time_analysis": "",
"estimates_written_converted": "", "estimates_written_converted": "",
"estimator_detail": "", "estimator_detail": "",
@@ -3857,6 +3873,18 @@
"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

@@ -656,6 +656,7 @@
} }
}, },
"labels": { "labels": {
"consent_settings": "",
"2tiername": "", "2tiername": "",
"2tiersetup": "", "2tiersetup": "",
"2tiersource": "", "2tiersource": "",
@@ -774,7 +775,6 @@
}, },
"labels": { "labels": {
"addtoproduction": "", "addtoproduction": "",
"allow_text_message": "",
"checklist": "", "checklist": "",
"printpack": "", "printpack": "",
"removefromproduction": "" "removefromproduction": ""
@@ -1230,7 +1230,11 @@
"fcm": "", "fcm": "",
"notfound": "", "notfound": "",
"sizelimit": "", "sizelimit": "",
"submit-for-testing": "" "submit-for-testing": "",
"sub_status": {
"expired": "",
"trial-expired": ""
}
}, },
"itemtypes": { "itemtypes": {
"contract": "", "contract": "",
@@ -1642,6 +1646,8 @@
"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",
@@ -1955,6 +1961,8 @@
"scheddates": "" "scheddates": ""
}, },
"labels": { "labels": {
"sent": "",
"approved": "",
"accountsreceivable": "", "accountsreceivable": "",
"act_price_ppc": "", "act_price_ppc": "",
"actual_completion_inferred": "", "actual_completion_inferred": "",
@@ -2030,6 +2038,7 @@
"deleteconfirm": "", "deleteconfirm": "",
"deletedelivery": "", "deletedelivery": "",
"deleteintake": "", "deleteintake": "",
"deletewatchers": "",
"deliverchecklist": "", "deliverchecklist": "",
"difference": "", "difference": "",
"diskscan": "", "diskscan": "",
@@ -2297,8 +2306,10 @@
"productionlist": "", "productionlist": "",
"readyjobs": "", "readyjobs": "",
"recent": "", "recent": "",
"remoteassist": "",
"reportcenter": "", "reportcenter": "",
"rescueme": "", "rescueme": "",
"rescuemezoho": "",
"schedule": "Programme", "schedule": "Programme",
"scoreboard": "", "scoreboard": "",
"search": { "search": {
@@ -2373,7 +2384,8 @@
"errors": { "errors": {
"invalidphone": "", "invalidphone": "",
"noattachedjobs": "", "noattachedjobs": "",
"updatinglabel": "" "updatinglabel": "",
"no_consent": ""
}, },
"labels": { "labels": {
"addlabel": "", "addlabel": "",
@@ -2389,7 +2401,8 @@
"selectmedia": "", "selectmedia": "",
"sentby": "", "sentby": "",
"typeamessage": "Envoyer un message...", "typeamessage": "Envoyer un message...",
"unarchive": "" "unarchive": "",
"no_consent": ""
}, },
"render": { "render": {
"conversation_list": "" "conversation_list": ""
@@ -2472,7 +2485,8 @@
"teams-search": "", "teams-search": "",
"unwatch": "", "unwatch": "",
"watch": "", "watch": "",
"watching-issue": "" "watching-issue": "",
"employee-notification": ""
}, },
"scenarios": { "scenarios": {
"alternate-transport-changed": "", "alternate-transport-changed": "",
@@ -2492,7 +2506,9 @@
"tasks-updated-created": "" "tasks-updated-created": ""
}, },
"tooltips": { "tooltips": {
"job-watchers": "" "job-watchers": "",
"not-employee": "",
"not-employee-notifications": ""
} }
}, },
"owner": { "owner": {
@@ -2513,7 +2529,6 @@
"fields": { "fields": {
"accountingid": "", "accountingid": "",
"address": "Adresse", "address": "Adresse",
"allow_text_message": "Autorisation de texte?",
"name": "Prénom", "name": "Prénom",
"note": "", "note": "",
"ownr_addr1": "Adresse", "ownr_addr1": "Adresse",
@@ -3094,6 +3109,7 @@
"credits_not_received_date_vendorid": "", "credits_not_received_date_vendorid": "",
"csi": "", "csi": "",
"customer_list": "", "customer_list": "",
"customer_list_excel": "",
"cycle_time_analysis": "", "cycle_time_analysis": "",
"estimates_written_converted": "", "estimates_written_converted": "",
"estimator_detail": "", "estimator_detail": "",
@@ -3857,6 +3873,18 @@
"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

@@ -1,6 +1,5 @@
import { Tooltip } from "antd"; import { Tooltip } from "antd";
import dayjs from "../utils/day"; import dayjs from "../utils/day";
import React from "react";
export function DateFormatter(props) { export function DateFormatter(props) {
return props.children ? dayjs(props.children).format(props.includeDay ? "ddd MM/DD/YYYY" : "MM/DD/YYYY") : null; return props.children ? dayjs(props.children).format(props.includeDay ? "ddd MM/DD/YYYY" : "MM/DD/YYYY") : null;

View File

@@ -2004,6 +2004,18 @@ export const TemplateList = (type, context) => {
}, },
group: "customers" group: "customers"
}, },
customer_list_excel: {
title: i18n.t("reportcenter.templates.customer_list_excel"),
subject: i18n.t("reportcenter.templates.customer_list_excel"),
key: "customer_list_excel",
reporttype: "excel",
disabled: false,
rangeFilter: {
object: i18n.t("reportcenter.labels.objects.jobs"),
field: i18n.t("jobs.fields.date_invoiced")
},
group: "customers"
},
exported_gsr_by_ro: { exported_gsr_by_ro: {
title: i18n.t("reportcenter.templates.exported_gsr_by_ro"), title: i18n.t("reportcenter.templates.exported_gsr_by_ro"),
subject: i18n.t("reportcenter.templates.exported_gsr_by_ro"), subject: i18n.t("reportcenter.templates.exported_gsr_by_ro"),
@@ -2241,7 +2253,7 @@ export const TemplateList = (type, context) => {
field: i18n.t("bills.fields.date") field: i18n.t("bills.fields.date")
}, },
group: "purchases" group: "purchases"
}, }
} }
: {}), : {}),
...(!type || type === "courtesycarcontract" ...(!type || type === "courtesycarcontract"

View File

@@ -0,0 +1,48 @@
import { phone } from "phone";
import { GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS } from "../graphql/phone-number-opt-out.queries";
/**
* Check if phone numbers are opted out for a given bodyshop
* @param {Object} apolloClient - Apollo Client instance
* @param {string} bodyshopId - The ID of the bodyshop
* @param {string[]} phoneNumbers - Array of phone numbers to check
* @returns {Promise<Set<string>>} - Set of normalized opted-out phone numbers
*/
export const phoneNumberOptOutService = async (apolloClient, bodyshopId, phoneNumbers) => {
if (!apolloClient || !bodyshopId || !phoneNumbers?.length) {
return new Set();
}
// Normalize phone numbers (remove +1 for CA numbers)
const normalizedPhones = phoneNumbers
.filter(Boolean)
.map((num) => phone(num, "CA").phoneNumber?.replace(/^\+1/, ""))
.filter(Boolean);
if (!normalizedPhones.length) {
return new Set();
}
const optedOutPhones = new Set();
try {
const { data } = await apolloClient.query({
query: GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS,
variables: {
bodyshopid: bodyshopId,
phone_numbers: normalizedPhones // Array of phone numbers
},
fetchPolicy: "network-only"
});
if (data?.phone_number_opt_out?.length) {
data.phone_number_opt_out.forEach((optOut) => {
optedOutPhones.add(optOut.phone_number);
});
}
} catch (error) {
console.error("Error checking opt-out statuses:", error);
}
return optedOutPhones;
};

View File

@@ -0,0 +1,19 @@
import { useMemo } from "react";
/**
* Check if the user is an employee of the bodyshop
* @param bodyshop
* @param userOrEmail
* @returns {boolean|*}
*/
export function useIsEmployee(bodyshop, userOrEmail) {
return useMemo(() => {
if (!bodyshop || !bodyshop.employees) return false;
// Handle both user object and email string
const email = typeof userOrEmail === "string" ? userOrEmail : userOrEmail?.email;
if (!email) return false;
return bodyshop.employees.some((employee) => employee.user_email === email);
}, [bodyshop, userOrEmail]);
}

View File

@@ -1035,6 +1035,7 @@
- use_fippa - use_fippa
- use_paint_scale_data - use_paint_scale_data
- uselocalmediaserver - uselocalmediaserver
- external_shop_id
- website - website
- workingdays - workingdays
- zip_post - zip_post
@@ -1130,6 +1131,7 @@
- use_fippa - use_fippa
- use_paint_scale_data - use_paint_scale_data
- uselocalmediaserver - uselocalmediaserver
- external_shop_id
- website - website
- workingdays - workingdays
- zip_post - zip_post
@@ -2681,6 +2683,9 @@
- active: - active:
_eq: true _eq: true
allow_aggregations: true allow_aggregations: true
- table:
name: integration_log
schema: public
- table: - table:
name: inventory name: inventory
schema: public schema: public
@@ -3702,6 +3707,8 @@
- est_ph1 - est_ph1
- est_st - est_st
- est_zip - est_zip
- estimate_approved
- estimate_sent_approval
- federal_tax_rate - federal_tax_rate
- flat_rate_ats - flat_rate_ats
- g_bett_amt - g_bett_amt
@@ -3976,6 +3983,8 @@
- est_ph1 - est_ph1
- est_st - est_st
- est_zip - est_zip
- estimate_approved
- estimate_sent_approval
- federal_tax_rate - federal_tax_rate
- flat_rate_ats - flat_rate_ats
- g_bett_amt - g_bett_amt
@@ -4262,6 +4271,8 @@
- est_ph1 - est_ph1
- est_st - est_st
- est_zip - est_zip
- estimate_approved
- estimate_sent_approval
- federal_tax_rate - federal_tax_rate
- flat_rate_ats - flat_rate_ats
- g_bett_amt - g_bett_amt
@@ -4733,6 +4744,7 @@
- id - id
- image - image
- image_path - image_path
- is_system
- isoutbound - isoutbound
- msid - msid
- read - read
@@ -5855,6 +5867,32 @@
template_engine: Kriti template_engine: Kriti
url: '{{$base_url}}/opensearch' url: '{{$base_url}}/opensearch'
version: 2 version: 2
- table:
name: phone_number_opt_out
schema: public
object_relationships:
- name: bodyshop
using:
foreign_key_constraint_on: bodyshopid
select_permissions:
- role: user
permission:
columns:
- phone_number
- created_at
- updated_at
- bodyshopid
- id
filter:
bodyshop:
associations:
_and:
- user:
authid:
_eq: X-Hasura-User-Id
- active:
_eq: true
comment: ""
- table: - table:
name: phonebook name: phonebook
schema: public schema: public

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