Compare commits

...

124 Commits

Author SHA1 Message Date
Dave Richer
28abd9707e feature/IO-3303-logging - Logging 2025-07-14 19:31:31 -04:00
Dave Richer
5f621e1ae0 feature/IO-3303-logging - Logging 2025-07-14 19:28:29 -04:00
Patrick Fic
fbd6766dcd Merged in feature/IO-3294-imgproxy-bill-upload-hotfix (pull request #2401)
IO-3294 fix upload to image proxy for bills.
2025-07-08 21:46:35 +00:00
Patrick Fic
9ace531edb IO-3294 fix upload to image proxy for bills. 2025-07-08 14:31:32 -07:00
Dave Richer
9c59fd4c00 Merged in release/2025-07-04 (pull request #2393)
DO NOT MERGE - PENDING Release/2025 07 04  into master-AIO - IO-3284 IO-3285 IO-3286 IO-3288
2025-07-05 01:21:35 +00:00
Allan Carr
a9f959cced Merged in feature/IO-3286-Additional-Product-Fruit-IDs (pull request #2394)
IO-3286 Additional Product Fruit IDs

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

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

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

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

View File

@@ -12791,27 +12791,6 @@
</translation>
</translations>
</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>
<name>checklist</name>
<definition_loaded>false</definition_loaded>
@@ -42614,27 +42593,6 @@
</translation>
</translations>
</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>
<name>name</name>
<definition_loaded>false</definition_loaded>

660
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,19 +12,19 @@
"@apollo/client": "^3.13.6",
"@emotion/is-prop-valid": "^1.3.1",
"@fingerprintjs/fingerprintjs": "^4.6.1",
"@firebase/analytics": "^0.10.13",
"@firebase/app": "^0.12.1",
"@firebase/auth": "^1.10.2",
"@firebase/firestore": "^4.7.12",
"@firebase/messaging": "^0.12.18",
"@firebase/analytics": "^0.10.16",
"@firebase/app": "^0.13.1",
"@firebase/auth": "^1.10.6",
"@firebase/firestore": "^4.7.17",
"@firebase/messaging": "^0.12.21",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.8.1",
"@sentry/cli": "^2.45.0",
"@sentry/react": "^9.18.0",
"@sentry/vite-plugin": "^3.4.0",
"@splitsoftware/splitio-react": "^2.1.1",
"@reduxjs/toolkit": "^2.8.2",
"@sentry/cli": "^2.46.0",
"@sentry/react": "^9.27.0",
"@sentry/vite-plugin": "^3.5.0",
"@splitsoftware/splitio-react": "^2.3.1",
"@tanem/react-nprogress": "^5.0.53",
"antd": "^5.25.1",
"antd": "^5.25.4",
"apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^4.3.0",
"autosize": "^6.0.1",
@@ -41,24 +41,25 @@
"i18next": "^24.2.3",
"i18next-browser-languagedetector": "^8.1.0",
"immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.8",
"libphonenumber-js": "^1.12.9",
"logrocket": "^9.0.2",
"markerjs2": "^2.32.4",
"memoize-one": "^6.0.0",
"normalize-url": "^8.0.1",
"normalize-url": "^8.0.2",
"object-hash": "^3.0.0",
"phone": "^3.1.59",
"prop-types": "^15.8.1",
"query-string": "^9.1.2",
"query-string": "^9.2.0",
"raf-schd": "^4.0.3",
"react": "^18.3.1",
"react-big-calendar": "^1.18.0",
"react-big-calendar": "^1.19.2",
"react-color": "^2.19.3",
"react-cookie": "^8.0.1",
"react-dom": "^18.3.1",
"react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.1",
"react-grid-layout": "1.3.4",
"react-i18next": "^15.4.1",
"react-i18next": "^15.5.2",
"react-icons": "^5.5.0",
"react-image-lightbox": "^5.1.4",
"react-markdown": "^10.1.0",
@@ -69,7 +70,7 @@
"react-resizable": "^3.0.5",
"react-router-dom": "^6.30.0",
"react-sticky": "^6.0.3",
"react-virtuoso": "^4.12.7",
"react-virtuoso": "^4.12.8",
"recharts": "^2.15.2",
"redux": "^5.0.1",
"redux-actions": "^3.0.3",
@@ -77,7 +78,7 @@
"redux-saga": "^1.3.0",
"redux-state-sync": "^3.1.4",
"reselect": "^5.1.1",
"sass": "^1.88.0",
"sass": "^1.89.1",
"socket.io-client": "^4.8.1",
"styled-components": "^6.1.18",
"subscriptions-transport-ws": "^0.11.0",
@@ -130,17 +131,17 @@
"@ant-design/icons": "^6.0.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.27.1",
"@dotenvx/dotenvx": "^1.44.0",
"@dotenvx/dotenvx": "^1.44.1",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.26.0",
"@eslint/js": "^9.28.0",
"@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/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@vitejs/plugin-react": "^4.3.4",
"browserslist": "^4.24.5",
"@vitejs/plugin-react": "^4.5.1",
"browserslist": "^4.25.0",
"browserslist-to-esbuild": "^2.1.1",
"chalk": "^5.4.1",
"eslint": "^8.57.1",
@@ -148,7 +149,7 @@
"eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0",
"jsdom": "^26.0.0",
"memfs": "^4.17.1",
"memfs": "^4.17.2",
"os-browserify": "^0.3.0",
"playwright": "^1.51.1",
"react-error-overlay": "^6.1.0",
@@ -160,7 +161,7 @@
"vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pwa": "^1.0.0",
"vite-plugin-style-import": "^2.0.0",
"vitest": "^3.1.3",
"vitest": "^3.2.3",
"workbox-window": "^7.3.0"
}
}

View File

@@ -2,10 +2,11 @@ import { useApolloClient, useMutation } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Checkbox, Form, Modal, Space } from "antd";
import _ from "lodash";
import React, { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { INSERT_NEW_BILL } from "../../graphql/bills.queries";
import { UPDATE_INVENTORY_LINES } from "../../graphql/inventory.queries";
import { UPDATE_JOB_LINE } from "../../graphql/jobs-lines.queries";
@@ -24,7 +25,7 @@ import BillFormContainer from "../bill-form/bill-form.container";
import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility";
import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility";
import { handleUpload } from "../documents-upload/documents-upload.utility";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility";
const mapStateToProps = createStructuredSelector({
billEnterModal: selectBillEnterModal,
@@ -53,10 +54,10 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
const notification = useNotification();
const {
treatments: { Enhanced_Payroll }
treatments: { Enhanced_Payroll, Imgproxy }
} = useSplitTreatments({
attributes: {},
names: ["Enhanced_Payroll"],
names: ["Enhanced_Payroll", "Imgproxy"],
splitKey: bodyshop.imexshopid
});
@@ -196,7 +197,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
job: { lbr_adjustments: newAdjustments }
}
});
if (!!jobUpdate.errors) {
if (jobUpdate.errors) {
notification["error"]({
message: t("jobs.errors.saving", {
message: JSON.stringify(jobUpdate.errors)
@@ -213,7 +214,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
variables: { partsLineIds: markPolReceived.map((p) => p.id) },
refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID"]
});
if (!!r2.errors) {
if (r2.errors) {
setLoading(false);
setEnterAgain(false);
notification["error"]({
@@ -224,7 +225,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
}
}
if (!!r1.errors) {
if (r1.errors) {
setLoading(false);
setEnterAgain(false);
notification["error"]({
@@ -244,7 +245,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
consumedbybillid: billId
}
});
if (!!r2.errors) {
if (r2.errors) {
setLoading(false);
setEnterAgain(false);
notification["error"]({
@@ -298,20 +299,39 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
});
});
} else {
upload.forEach((u) => {
handleUpload(
{ file: u.originFileObj },
{
bodyshop: bodyshop,
uploaded_by: currentUser.email,
jobId: values.jobid,
billId: billId,
tagsArray: null,
callback: null
},
notification
);
});
//Check if using Imgproxy or cloudinary
if (Imgproxy.treatment === "on") {
upload.forEach((u) => {
handleUploadToImageProxy(
{ file: u.originFileObj },
{
bodyshop: bodyshop,
uploaded_by: currentUser.email,
jobId: values.jobid,
billId: billId,
tagsArray: null,
callback: null
},
notification
);
});
} else {
upload.forEach((u) => {
handleUpload(
{ file: u.originFileObj },
{
bodyshop: bodyshop,
uploaded_by: currentUser.email,
jobId: values.jobid,
billId: billId,
tagsArray: null,
callback: null
},
notification
);
});
}
}
}
///////////////////////////
@@ -396,7 +416,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
{t("bills.labels.generatepartslabel")}
</Checkbox>
<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")}
</Button>
{billEnterModal.context && billEnterModal.context.id ? null : (
@@ -406,6 +426,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
onClick={() => {
setEnterAgain(true);
}}
id="save-and-new-bill-enter-modal"
>
{t("general.actions.saveandnew")}
</Button>

View File

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

View File

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

View File

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

View File

@@ -202,8 +202,6 @@ export const registerMessagingHandlers = ({ socket, client }) => {
text: message.text
};
// Add cases for other known message types as needed
default:
// Log a warning for unhandled message types
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
? [
newConversation,
...existingList.conversations.filter((conv) => conv.id !== newConversation.id) // Prevent duplicates
]
: [newConversation];
? [newConversation, ...existingList.conversations.filter((conv) => conv.id !== newConversation.id)]
: [newConversation]; // Prevent duplicates
client.cache.writeQuery({
query: CONVERSATION_LIST_QUERY,
@@ -403,6 +398,7 @@ export const registerMessagingHandlers = ({ socket, client }) => {
}
break;
default:
logLocal("handleConversationChanged - Unhandled type", { type });
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-detailed", handleNewMessageDetailed);
socket.on("message-changed", handleMessageChanged);
socket.on("conversation-changed", handleConversationChanged);
socket.on("phone-number-opted-out", handlePhoneNumberOptedOut);
socket.on("phone-number-opted-in", handlePhoneNumberOptedIn);
};
export const unregisterMessagingHandlers = ({ socket }) => {
@@ -431,4 +512,6 @@ export const unregisterMessagingHandlers = ({ socket }) => {
socket.off("new-message-detailed");
socket.off("message-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 React, { useEffect, useState } from "react";
import { Badge, Card, List, Space, Tag, Tooltip } from "antd";
import { useEffect, useMemo, useState } from "react";
import { connect } from "react-redux";
import { Virtuoso } from "react-virtuoso";
import { createStructuredSelector } from "reselect";
@@ -9,36 +9,62 @@ import { TimeAgoFormatter } from "../../utils/DateFormatter";
import PhoneFormatter from "../../utils/PhoneFormatter";
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import _ from "lodash";
import { ExclamationCircleOutlined } from "@ant-design/icons";
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({
selectedConversation: selectSelectedConversation
selectedConversation: selectSelectedConversation,
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId))
});
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) {
// That comma is there for a reason, do not remove it
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) {
const { t } = useTranslation();
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(() => {
const interval = setInterval(() => {
forceUpdate((prev) => !prev); // Toggle state to trigger re-render
}, 60000); // 1 minute in milliseconds
return () => clearInterval(interval); // Cleanup on unmount
forceUpdate((prev) => !prev);
}, 60000);
return () => clearInterval(interval);
}, []);
// Memoize the sorted conversation list
const sortedConversationList = React.useMemo(() => {
const sortedConversationList = useMemo(() => {
return _.orderBy(conversationList, ["updated_at"], ["desc"]);
}, [conversationList]);
const renderConversation = (index) => {
const renderConversation = (index, t) => {
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 cardContentLeft =
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 = () =>
item.id === selectedConversation
@@ -73,9 +110,25 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
onClick={() => setSelectedConversation(item.id)}
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
>
<Card style={getCardStyle()} bordered={false} size="small" extra={cardExtra} title={cardTitle}>
<div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div>
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
<Card style={getCardStyle()} variant={true} size="small" extra={cardExtra} title={cardTitle}>
<div
style={{
display: "inline-block",
width: "70%",
textAlign: "left"
}}
>
{cardContentLeft}
</div>
<div
style={{
display: "inline-block",
width: "30%",
textAlign: "right"
}}
>
{cardContentRight}
</div>
</Card>
</List.Item>
);
@@ -85,7 +138,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
<div className="chat-list-container">
<Virtuoso
data={sortedConversationList}
itemContent={(index) => renderConversation(index)}
itemContent={(index) => renderConversation(index, t)}
style={{ height: "100%", width: "100%" }}
/>
</div>

View File

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

View File

@@ -58,6 +58,7 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
userid
created_at
read
is_system
}
`,
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 JobDocumentsLocalGalleryExternal from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import "./chat-media-selector.styles.scss";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
const mapDispatchToProps = (dispatch) => ({});
export default connect(mapStateToProps, mapDispatchToProps)(ChatMediaSelector);
export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, conversation }) {
@@ -37,9 +38,8 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
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
});
@@ -56,25 +56,25 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
//If Imageproxy is on, rely only on the LMS selector
//If not on, use the old methods.
const content = (
<div>
<div className="media-selector-content">
{loading && <LoadingSpinner />}
{error && <AlertComponent message={error.message} type="error" />}
{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}
{Imgproxy.treatment === "on" ? (
<>
{!bodyshop.uselocalmediaserver && (
<JobsDocumentImgproxyGalleryExternal
jobId={conversation.job_conversations[0].jobid}
jobId={conversation.job_conversations[0]?.jobid}
externalMediaState={[selectedMedia, setSelectedMedia]}
/>
)}
{bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal
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 && (
<JobDocumentsLocalGalleryExternal
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 (
<Popover
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")}
trigger="click"
open={open}
onOpenChange={handleVisibleChange}
classNames={{ root: "media-selector-popover" }}
>
<Badge count={selectedMedia.filter((s) => s.isSelected).length}>
<PictureFilled style={{ margin: "0 .5rem" }} />

View File

@@ -0,0 +1,48 @@
.media-selector-popover {
.ant-popover-inner-content {
position: relative;
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;
}
/* 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%;
width: 100%;
}
.archive-button {
height: 20px;
border-radius: 4px;
}
.chat-title {
margin-bottom: 5px;
}
.messages {
display: flex;
flex-direction: column;
@@ -37,11 +40,13 @@
gap: 8px;
}
}
.chat-send-message-button{
.chat-send-message-button {
margin: 0.3rem;
padding-left: 0.5rem;
}
.message-icon {
position: absolute;
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 {
flex: 1;
overflow: auto;

View File

@@ -2,17 +2,29 @@ import Icon from "@ant-design/icons";
import { Tooltip } from "antd";
import i18n from "i18next";
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";
export const renderMessage = (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 (
<div key={index} className={`${message.isoutbound ? "mine messages" : "yours messages"}`}>
<div key={index} className={messageClass}>
<div className="message msgmargin">
<Tooltip title={DateTimeFormatter({ children: message.created_at })}>
<Tooltip title={tooltipTitle}>
<div>
{isSystem && <span className="system-label">System</span>}
{/* Render images if available */}
{message.image && message.image_path?.length > 0 && (
<div className="message-images">
@@ -26,20 +38,31 @@ export const renderMessage = (messages, index) => {
</div>
)}
{/* 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>
</Tooltip>
{/* Message status icons */}
{message.status && (message.status === "sent" || message.status === "delivered") && (
<div className="message-status">
<Icon component={message.status === "sent" ? MdDone : MdDoneAll} className="message-icon" />
</div>
)}
{/* Message status icons for non-system messages */}
{!isSystem &&
message.status &&
(message.status === "sent" || message.status === "delivered" || message.status === "failed") && (
<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>
{/* Outbound message metadata */}
{message.isoutbound && (
{/* Outbound message metadata for non-system messages */}
{!isSystem && message.isoutbound && (
<div style={{ fontSize: 10 }}>
{i18n.t("messaging.labels.sentby", {
by: message.userid,

View File

@@ -1,6 +1,6 @@
import { LoadingOutlined, SendOutlined } from "@ant-design/icons";
import { Input, Spin } from "antd";
import React, { useEffect, useRef, useState } from "react";
import { ExclamationCircleOutlined, LoadingOutlined, SendOutlined } from "@ant-design/icons";
import { Alert, Input, Space, Spin, Tooltip } from "antd";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -10,6 +10,9 @@ import { selectIsSending, selectMessage } from "../../redux/messaging/messaging.
import { selectBodyshop } from "../../redux/user/user.selectors";
import ChatMediaSelector from "../chat-media-selector/chat-media-selector.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({
bodyshop: selectBodyshop,
@@ -25,16 +28,24 @@ const mapDispatchToProps = (dispatch) => ({
function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSending, message, setMessage }) {
const inputArea = useRef(null);
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(() => {
inputArea.current.focus();
}, [isSending, setMessage]);
const { t } = useTranslation();
const handleEnter = () => {
const selectedImages = selectedMedia.filter((i) => i.isSelected);
if ((message === "" || !message) && selectedImages.length === 0) return;
if (isOptedOut) return; // Prevent sending if phone number is opted out
logImEXEvent("messaging_send_message");
if (selectedImages.length < 11) {
@@ -44,7 +55,8 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
messagingServiceSid: bodyshop.messagingservicesid,
conversationid: conversation.id,
selectedMedia: selectedImages,
imexshopid: bodyshop.imexshopid
imexshopid: bodyshop.imexshopid,
bodyshopid: bodyshop.id
};
sendMessage(newMessage);
setSelectedMedia(
@@ -56,47 +68,67 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
};
return (
<div className="imex-flex-row" style={{ width: "100%" }}>
<ChatPresetsComponent className="imex-flex-row__margin" />
<ChatMediaSelector
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}
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
<Space direction="vertical" style={{ width: "100%" }} size="middle">
{isOptedOut && (
<Tooltip title={t("consent.text_body")}>
<Alert
showIcon={true}
icon={<ExclamationCircleOutlined />}
message={t("messaging.errors.no_consent")}
type="error"
/>
}
/>
</div>
</Tooltip>
)}
<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

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

View File

@@ -15,6 +15,7 @@ import {
HomeFilled,
ImportOutlined,
LineChartOutlined,
OneToOneOutlined,
PaperClipOutlined,
PhoneOutlined,
PlusCircleOutlined,
@@ -24,6 +25,7 @@ import {
TeamOutlined,
ToolFilled,
UnorderedListOutlined,
UsergroupAddOutlined,
UserOutlined
} from "@ant-design/icons";
import { useQuery } from "@apollo/client";
@@ -40,6 +42,7 @@ import { RiSurveyLine } from "react-icons/ri";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
import { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions";
@@ -47,11 +50,10 @@ import { signOutStart } from "../../redux/user/user.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import day from "../../utils/day.js";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
// Redux mappings
const mapStateToProps = createStructuredSelector({
@@ -642,17 +644,32 @@ function Header({
label: t("menus.header.help"),
onClick: () => window.open("https://help.imex.online/", "_blank")
},
...(InstanceRenderManager({ imex: true, rome: false })
? [
{
key: "rescue",
id: "header-rescue",
icon: <CarFilled />,
label: t("menus.header.rescueme"),
onClick: () => window.open("https://imexrescue.com/", "_blank")
}
]
: []),
{
key: "remoteassist",
id: "header-remote-assist",
icon: <OneToOneOutlined />,
label: t("menus.header.remoteassist"),
children: [
...(InstanceRenderManager({ imex: true, rome: false })
? [
{
key: "rescue",
id: "header-rescue",
icon: <PlusCircleOutlined />,
label: t("menus.header.rescueme"),
onClick: () => window.open("https://imexrescue.com/", "_blank")
}
]
: []),
{
key: "rescue-zoho",
id: "header-rescue-zoho",
icon: <UsergroupAddOutlined />,
label: t("menus.header.rescuemezoho"),
onClick: () => window.open("https://join.zoho.com/", "_blank")
}
]
},
{
key: "shiftclock",
id: "header-shiftclock",

View File

@@ -395,32 +395,33 @@ export function ScheduleEventComponent({
) : (
<ScheduleManualEvent event={event} />
)}
{event.isintake && HasFeatureAccess({ featureName: "checklist", bodyshop }) ? (
<Link
to={{
pathname: `/manage/jobs/${event.job && event.job.id}/intake`,
search: `?appointmentId=${event.id}`
}}
>
<Button disabled={event.arrived}>{t("appointments.actions.intake")}</Button>
</Link>
) : (
<Popover //open={open}
content={popMenu}
open={popOverVisible}
onOpenChange={setPopOverVisible}
onClick={(e) => {
if (event.job?.id) {
e.stopPropagation();
getJobDetails();
}
}}
getPopupContainer={(trigger) => trigger.parentNode}
trigger="click"
>
<Button disabled={event.arrived}>{t("jobs.actions.intake_quick")}</Button>
</Popover>
)}
{event.job &&
(HasFeatureAccess({ featureName: "checklist", bodyshop }) ? (
<Link
to={{
pathname: `/manage/jobs/${event.job && event.job.id}/intake`,
search: `?appointmentId=${event.id}`
}}
>
<Button disabled={event.arrived}>{t("appointments.actions.intake")}</Button>
</Link>
) : (
<Popover //open={open}
content={popMenu}
open={popOverVisible}
onOpenChange={setPopOverVisible}
onClick={(e) => {
if (event.job?.id) {
e.stopPropagation();
getJobDetails();
}
}}
getPopupContainer={(trigger) => trigger.parentNode}
trigger="click"
>
<Button disabled={event.arrived}>{t("jobs.actions.intake_quick")}</Button>
</Popover>
))}
</Space>
</div>
);

View File

@@ -1,7 +1,7 @@
import { useMutation } from "@apollo/client";
import { Button, Card, Form, Input, Switch } from "antd";
import queryString from "query-string";
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useLocation, useNavigate, useParams } from "react-router-dom";
@@ -9,7 +9,6 @@ import { createStructuredSelector } from "reselect";
import { logImEXEvent } from "../../../../firebase/firebase.utils";
import { MARK_APPOINTMENT_ARRIVED, MARK_LATEST_APPOINTMENT_ARRIVED } from "../../../../graphql/appointments.queries";
import { UPDATE_JOB } from "../../../../graphql/jobs.queries";
import { UPDATE_OWNER } from "../../../../graphql/owners.queries";
import { insertAuditTrail } from "../../../../redux/application/application.actions";
import { selectBodyshop, selectCurrentUser } from "../../../../redux/user/user.selectors";
import AuditTrailMapping from "../../../../utils/AuditTrailMappings";
@@ -32,7 +31,6 @@ export function JobChecklistForm({ insertAuditTrail, formItems, bodyshop, curren
const [loading, setLoading] = useState(false);
const [markAptArrived] = useMutation(MARK_APPOINTMENT_ARRIVED);
const [markLatestAptArrived] = useMutation(MARK_LATEST_APPOINTMENT_ARRIVED);
const [updateOwner] = useMutation(UPDATE_OWNER);
const notification = useNotification();
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);
if (!!!result.errors) {
@@ -189,7 +169,6 @@ export function JobChecklistForm({ insertAuditTrail, formItems, bodyshop, curren
initialValues={{
...(type === "intake" && {
addToProduction: true,
allow_text_message: job.owner && job.owner.allow_text_message,
scheduled_completion:
(job && job.scheduled_completion && dayjs(job.scheduled_completion)) ||
(job &&
@@ -228,14 +207,6 @@ export function JobChecklistForm({ insertAuditTrail, formItems, bodyshop, curren
>
<Switch disabled={readOnly} />
</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
name="scheduled_completion"
label={t("jobs.fields.scheduled_completion")}

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { Form, Input, Switch } from "antd";
import { Form, Input } from "antd";
import React, { useContext } from "react";
import { useTranslation } from "react-i18next";
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"]}>
<Input disabled={!state.owner.new} />
</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>
</div>
);

View File

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

View File

@@ -72,7 +72,7 @@ export default function JobsCreateVehicleInfoPredefined({ disabled, form }) {
open={open}
placement="left"
onOpenChange={handleOpenChange}
destroyTooltipOnHide
destroyOnHidden
>
<SearchOutlined style={{ cursor: "pointer" }} />
</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 { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { alphaSort } from "../../utils/sorters";
import JobCreateContext from "../../pages/jobs-create/jobs-create.context";
import { alphaSort } from "../../utils/sorters";
import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component";
export default function JobsCreateVehicleInfoSearchComponent({ loading, vehicles }) {
@@ -63,6 +63,7 @@ export default function JobsCreateVehicleInfoSearchComponent({ loading, vehicles
});
}}
enterButton
id="search-vehicle"
/>
</Space>
}
@@ -91,9 +92,9 @@ export default function JobsCreateVehicleInfoSearchComponent({ loading, vehicles
type: "radio",
selectedRowKeys: [state.vehicle.selectedid]
}}
onRow={(record, rowIndex) => {
onRow={(record) => {
return {
onClick: (event) => {
onClick: () => {
if (record) {
if (record.id) {
setState({

View File

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

View File

@@ -167,7 +167,18 @@ export function JobsDetailHeaderActionsToggleProduction({
<FormDateTimePickerComponent disabled={jobRO} />
</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} />
</Form.Item>
</>

View File

@@ -1,17 +1,20 @@
import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined, WarningFilled } from "@ant-design/icons";
import { useMutation } from "@apollo/client";
import { Card, Checkbox, Col, Divider, Row, Space, Tag, Tooltip } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { useMutation } from "@apollo/client";
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 { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { UPDATE_JOB } from "../../graphql/jobs.queries";
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateTimeFormatter } from "../../utils/DateFormatter";
import { DateTimeFormatter, DateTimeFormatterFunction } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import ChatOpenButton from "../chat-open-button/chat-open-button.component";
@@ -24,7 +27,6 @@ import ProductionListColumnComment from "../production-list-columns/production-l
import ProductionListColumnProductionNote from "../production-list-columns/production-list-columns.productionnote.component";
import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component";
import "./jobs-detail-header.styles.scss";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly,
@@ -38,6 +40,14 @@ const mapDispatchToProps = (dispatch) => ({
context: context,
modal: "printCenter"
})
),
insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
insertAuditTrail({
jobid,
operation,
type
})
)
});
@@ -49,7 +59,7 @@ const colSpan = {
xl: { span: 6 }
};
export function JobsDetailHeader({ job, bodyshop, disabled }) {
export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail }) {
const { t } = useTranslation();
const { notification } = useNotification();
const [notesClamped, setNotesClamped] = useState(true);
@@ -66,7 +76,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
const handleCheckboxChange = async (field, checked) => {
const value = checked ? dayjs().toISOString() : null;
try {
await updateJob({
const ret = await updateJob({
variables: {
jobId: job.id,
job: { [field]: value }
@@ -74,6 +84,16 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
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 })
@@ -315,7 +335,11 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
</Card>
</Col>
<Col {...colSpan}>
<Card style={{ height: "100%" }} title={t("jobs.labels.employeeassignments")}>
<Card
style={{ height: "100%" }}
title=<span id="job-employee-assignments-title">{t("jobs.labels.employeeassignments")}</span>
id={"job-employee-assignments"}
>
<div>
<JobEmployeeAssignments job={job} />
<Divider style={{ margin: ".5rem" }} />

View File

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

View File

@@ -46,32 +46,40 @@ export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, i
}
function standardMediaDownload(bufferData) {
const a = document.createElement("a");
const url = window.URL.createObjectURL(new Blob([bufferData]));
a.href = url;
a.download = `${identifier || "documents"}.zip`;
a.click();
try {
const a = document.createElement("a");
const url = window.URL.createObjectURL(new Blob([bufferData]));
a.href = url;
a.download = `${identifier || "documents"}.zip`;
a.click();
} catch (error) {
setLoading(false);
setDownload(null);
}
}
const handleDownload = async () => {
logImEXEvent("jobs_documents_download");
setLoading(true);
const zipUrl = await axios({
url: "/media/imgproxy/download",
method: "POST",
data: { jobId, documentids: imagesToDownload.map((_) => _.id) }
});
try {
const response = await axios({
url: "/media/imgproxy/download",
method: "POST",
responseType: "blob",
data: { jobId, documentids: imagesToDownload.map((_) => _.id) },
onDownloadProgress: downloadProgress
});
const theDownloadedZip = await cleanAxios({
url: zipUrl.data.url,
method: "GET",
responseType: "arraybuffer",
onDownloadProgress: downloadProgress
});
setLoading(false);
setDownload(null);
setLoading(false);
setDownload(null);
standardMediaDownload(theDownloadedZip.data);
// Use the response data (Blob) to trigger download
standardMediaDownload(response.data);
} catch (error) {
setLoading(false);
setDownload(null);
// handle error (optional)
}
};
return (

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,15 @@
import { Form, Input, Switch } from "antd";
import React from "react";
import { Form, Input, Tooltip } from "antd";
import { CloseCircleFilled } from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.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 { 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 { getFieldValue } = form;
return (
<div>
<FormFieldsChanged form={form} />
@@ -26,7 +27,7 @@ export default function OwnerDetailFormComponent({ form, loading }) {
<Input />
</Form.Item>
<Form.Item label={t("owners.fields.accountingid")} name="accountingid">
<Input disabled/>
<Input disabled />
</Form.Item>
</LayoutFormRow>
<LayoutFormRow header={t("owners.forms.address")}>
@@ -50,9 +51,6 @@ export default function OwnerDetailFormComponent({ form, loading }) {
</Form.Item>
</LayoutFormRow>
<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
label={t("owners.fields.ownr_ea")}
name="ownr_ea"
@@ -65,19 +63,55 @@ export default function OwnerDetailFormComponent({ form, loading }) {
>
<FormItemEmail email={getFieldValue("ownr_ea")} />
</Form.Item>
<Form.Item
label={t("owners.fields.ownr_ph1")}
name="ownr_ph1"
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph1")]}
>
<FormItemPhone />
<Form.Item label={t("owners.fields.ownr_ph1")} style={{ marginBottom: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Form.Item
name="ownr_ph1"
noStyle
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
label={t("owners.fields.ownr_ph2")}
name="ownr_ph2"
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph2")]}
>
<FormItemPhone />
<Form.Item label={t("owners.fields.ownr_ph2")} style={{ marginBottom: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Form.Item
name="ownr_ph2"
noStyle
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 label={t("owners.fields.preferred_contact")} name="preferred_contact">
<Input />

View File

@@ -1,69 +1,115 @@
import { Button, Form, Popconfirm } from "antd";
import { PageHeader } from "@ant-design/pro-layout";
import React, { useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation } from "@apollo/client";
import { useApolloClient, useMutation } from "@apollo/client";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
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 { 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 [form] = Form.useForm();
const history = useNavigate();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [optedOutPhones, setOptedOutPhones] = useState(new Set());
const [updateOwner] = useMutation(UPDATE_OWNER);
const [deleteOwner] = useMutation(DELETE_OWNER);
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 () => {
setLoading(true);
const result = await deleteOwner({
variables: { id: owner.id }
});
console.log(result);
if (result.errors) {
notification["error"]({
try {
const result = await deleteOwner({
variables: { id: owner.id }
});
if (result.errors) {
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", {
error: JSON.stringify(result.errors)
error: error.message
})
});
} finally {
setLoading(false);
} else {
notification["success"]({
message: t("owners.successes.delete")
});
setLoading(false);
history(`/manage/owners`);
}
};
const handleFinish = async (values) => {
setLoading(true);
const result = await updateOwner({
variables: { ownerId: owner.id, owner: values }
});
if (!!result.errors) {
notification["error"]({
try {
const result = await updateOwner({
variables: { ownerId: owner.id, owner: values }
});
if (result.errors) {
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", {
error: JSON.stringify(result.errors)
error: error.message
})
});
} finally {
setLoading(false);
return;
}
notification["success"]({
message: t("owners.successes.save")
});
if (refetch) await refetch();
form.resetFields();
form.resetFields();
setLoading(false);
};
return (
@@ -72,6 +118,7 @@ function OwnerDetailFormContainer({ owner, refetch }) {
title={t("menus.header.owners")}
extra={[
<Popconfirm
key="delete"
trigger="click"
onConfirm={handleDelete}
disabled={owner.jobs.length !== 0}
@@ -81,16 +128,29 @@ function OwnerDetailFormContainer({ owner, refetch }) {
{t("general.actions.delete")}
</Button>
</Popconfirm>,
<Button type="primary" loading={loading} onClick={() => form.submit()}>
<Button key="save" type="primary" loading={loading} onClick={() => form.submit()}>
{t("general.actions.save")}
</Button>
]}
/>
<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>
</>
);
}
export default OwnerDetailFormContainer;
export default connect(mapStateToProps)(OwnerDetailFormContainer);

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -133,7 +133,9 @@ const Lane = ({
Item: ItemComponent
},
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 = {
@@ -149,8 +151,6 @@ const Lane = ({
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
? orientation === "horizontal"
? {
@@ -161,9 +161,8 @@ const Lane = ({
: {}
: 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
// a card is dragged over it
const shouldRenderPlaceholder = orientation !== "horizontal" && (collapsed || renderedCards.length === 0);
// Always render placeholder for empty lanes in vertical mode to ensure droppable area
const shouldRenderPlaceholder = orientation === "vertical" ? collapsed || renderedCards.length === 0 : collapsed;
return (
<HeightMemoryWrapper
@@ -178,8 +177,8 @@ const Lane = ({
override={orientation !== "horizontal" && (collapsed || !renderedCards.length)}
>
<div
ref={laneRef} // Ensure laneRef is set here
style={{ height: "100%", width: "100%" }} // Make it scrollable
ref={laneRef}
style={{ height: "100%", width: "100%" }}
className={`react-trello-lane ${collapsed ? "lane-collapsed" : ""}`}
>
<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]);
return (
<Popover destroyTooltipOnHide content={popContent} open={visibility}>
<Popover destroyOnHidden content={popContent} open={visibility}>
<Spin spinning={loading}>
{record[type] ? (
<div>

View File

@@ -6,6 +6,7 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { QUERY_ACTIVE_EMPLOYEES, QUERY_ACTIVE_EMPLOYEES_WITH_EMAIL } from "../../graphql/employees.queries";
import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries";
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 BlurWrapperComponent from "../feature-wrapper/blur-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 ReportCenterModalFiltersSortersComponent from "./report-center-modal-filters-sorters-component";
import "./report-center-modal.styles.scss";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
reportCenterModal: selectReportCenter,
@@ -389,5 +389,7 @@ const restrictedReports = [
{ key: "job_costing_ro_date_detail", days: 183 },
{ key: "job_costing_ro_estimator", 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

@@ -1,5 +1,5 @@
import { Col, Row } from "antd";
import React, { useEffect } from "react";
import { useEffect } from "react";
import ScoreboardChart from "../scoreboard-chart/scoreboard-chart.component";
import ScoreboardLastDays from "../scoreboard-last-days/scoreboard-last-days.component";
import ScoreboardTargetsTable from "../scoreboard-targets-table/scoreboard-targets-table.component";
@@ -26,18 +26,18 @@ export function ScoreboardDisplayComponent({ bodyshop }) {
start: dayjs().startOf("month"),
end: dayjs().endOf("month")
},
pollInterval: 60000*5
pollInterval: 60000 * 5
});
const { data } = scoreboardSubscription;
const client = useApolloClient();
const scoreBoardlist = (data && data.scoreboard) || [];
const scoreBoardlist = data?.scoreboard || [];
const sbEntriesByDate = {};
scoreBoardlist.forEach((i) => {
const entryDate = i.date;
if (!!!sbEntriesByDate[entryDate]) {
if (!sbEntriesByDate[entryDate]) {
sbEntriesByDate[entryDate] = [];
}
sbEntriesByDate[entryDate].push(i);
@@ -58,10 +58,9 @@ export function ScoreboardDisplayComponent({ bodyshop }) {
});
appointments = data.appointments;
}
dayjs.updateLocale("ca", {
dayjs.updateLocale(dayjs.locale(), {
workingWeekdays: translateSettingsToWorkingDays(bodyshop.workingdays),
...(appointments
...(appointments?.length
? {
holidays: appointments.map((h) => dayjs(h.start).format("MM-DD-YYYY"))
}
@@ -78,11 +77,9 @@ export function ScoreboardDisplayComponent({ bodyshop }) {
<Col span={24}>
<ScoreboardTargetsTable scoreBoardlist={scoreBoardlist} />
</Col>
<Col span={24}>
<ScoreboardLastDays sbEntriesByDate={sbEntriesByDate} />
</Col>
<Col span={24}>
<ScoreboardChart sbEntriesByDate={sbEntriesByDate} />
</Col>

View File

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

View File

@@ -1,35 +1,34 @@
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Card, Tabs } from "antd";
import React from "react";
import queryString from "query-string";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
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 ShopInfoIntakeChecklistComponent from "./shop-info.intake.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 ShopInfoPartsScan from "./shop-info.parts-scan";
import ShopInfoRbacComponent from "./shop-info.rbac.component";
import ShopInfoResponsibilityCenterComponent from "./shop-info.responsibilitycenters.component";
import ShopInfoRoGuard from "./shop-info.roguard.component";
import ShopInfoROStatusComponent from "./shop-info.rostatus.component";
import ShopInfoSchedulingComponent from "./shop-info.scheduling.component";
import ShopInfoSpeedPrint from "./shop-info.speedprint.component";
import { useLocation, useNavigate } from "react-router-dom";
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 { 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({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoComponent);
@@ -158,7 +157,7 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
return (
<Card
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")}
</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 { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, DatePicker, Form, Input, InputNumber, Radio, Select, Space, Switch } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -15,11 +14,12 @@ import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-forma
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
// eslint-disable-next-line no-undef
const timeZonesList = Intl.supportedValuesOf("timeZone");
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoGeneral);
@@ -144,236 +144,246 @@ export function ShopInfoGeneral({ form, bodyshop }) {
<InputNumber min={0} />
</Form.Item>
</LayoutFormRow>
<FeatureWrapper featureName="export" noauth={() => null}>
<LayoutFormRow header={t("bodyshop.labels.accountingsetup")} id="accountingsetup">
<Form.Item label={t("bodyshop.labels.qbo")} valuePropName="checked" name={["accountingconfig", "qbo"]}>
<Switch />
</Form.Item>
{InstanceRenderManager({
imex: (
<Form.Item shouldUpdate noStyle>
{() => (
<LayoutFormRow header={t("bodyshop.labels.accountingsetup")} id="accountingsetup">
{HasFeatureAccess({ featureName: "export", bodyshop }) && (
<>
<Form.Item label={t("bodyshop.labels.qbo")} valuePropName="checked" name={["accountingconfig", "qbo"]}>
<Switch />
</Form.Item>
{InstanceRenderManager({
imex: (
<Form.Item shouldUpdate noStyle>
{() => (
<Form.Item
label={t("bodyshop.labels.qbo_usa")}
shouldUpdate
valuePropName="checked"
name={["accountingconfig", "qbo_usa"]}
>
<Switch disabled={!form.getFieldValue(["accountingconfig", "qbo"])} />
</Form.Item>
)}
</Form.Item>
)
})}
<Form.Item label={t("bodyshop.labels.qbo_departmentid")} name={["accountingconfig", "qbo_departmentid"]}>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.labels.accountingtiers")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["accountingconfig", "tiers"]}
>
<Radio.Group>
<Radio value={2}>2</Radio>
<Radio value={3}>3</Radio>
</Radio.Group>
</Form.Item>
<Form.Item shouldUpdate>
{() => {
return (
<Form.Item
label={t("bodyshop.labels.qbo_usa")}
label={t("bodyshop.labels.2tiersetup")}
shouldUpdate
valuePropName="checked"
name={["accountingconfig", "qbo_usa"]}
rules={[
{
required: form.getFieldValue(["accountingconfig", "tiers"]) === 2
//message: t("general.validation.required"),
}
]}
name={["accountingconfig", "twotierpref"]}
>
<Switch disabled={!form.getFieldValue(["accountingconfig", "qbo"])} />
<Radio.Group disabled={form.getFieldValue(["accountingconfig", "tiers"]) === 3}>
<Radio value="name">{t("bodyshop.labels.2tiername")}</Radio>
<Radio value="source">{t("bodyshop.labels.2tiersource")}</Radio>
</Radio.Group>
</Form.Item>
)}
</Form.Item>
)
})}
<Form.Item label={t("bodyshop.labels.qbo_departmentid")} name={["accountingconfig", "qbo_departmentid"]}>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.labels.accountingtiers")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["accountingconfig", "tiers"]}
>
<Radio.Group>
<Radio value={2}>2</Radio>
<Radio value={3}>3</Radio>
</Radio.Group>
</Form.Item>
<Form.Item shouldUpdate>
{() => {
return (
);
}}
</Form.Item>
<Form.Item
label={t("bodyshop.labels.printlater")}
valuePropName="checked"
name={["accountingconfig", "printlater"]}
>
<Switch />
</Form.Item>
<Form.Item
label={t("bodyshop.labels.emaillater")}
valuePropName="checked"
name={["accountingconfig", "emaillater"]}
>
<Switch />
</Form.Item>
</>
)}
<Form.Item
label={t("bodyshop.fields.inhousevendorid")}
name={"inhousevendorid"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.default_adjustment_rate")}
name={"default_adjustment_rate"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={0} precision={2} />
</Form.Item>
{InstanceRenderManager({
imex: (
<Form.Item label={t("bodyshop.fields.federal_tax_id")} name="federal_tax_id">
<Input />
</Form.Item>
)
})}
<Form.Item label={t("bodyshop.fields.state_tax_id")} name="state_tax_id">
<Input />
</Form.Item>
{HasFeatureAccess({ featureName: "bills", bodyshop }) && (
<>
{InstanceRenderManager({
imex: (
<Form.Item
label={t("bodyshop.labels.2tiersetup")}
shouldUpdate
label={t("bodyshop.fields.invoice_federal_tax_rate")}
name={["bill_tax_rates", "federal_tax_rate"]}
rules={[
{
required: form.getFieldValue(["accountingconfig", "tiers"]) === 2
required: true
//message: t("general.validation.required"),
}
]}
name={["accountingconfig", "twotierpref"]}
>
<Radio.Group disabled={form.getFieldValue(["accountingconfig", "tiers"]) === 3}>
<Radio value="name">{t("bodyshop.labels.2tiername")}</Radio>
<Radio value="source">{t("bodyshop.labels.2tiersource")}</Radio>
</Radio.Group>
<InputNumber />
</Form.Item>
);
}}
</Form.Item>
<Form.Item
label={t("bodyshop.labels.printlater")}
valuePropName="checked"
name={["accountingconfig", "printlater"]}
>
<Switch />
</Form.Item>
<Form.Item
label={t("bodyshop.labels.emaillater")}
valuePropName="checked"
name={["accountingconfig", "emaillater"]}
>
<Switch />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.inhousevendorid")}
name={"inhousevendorid"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.default_adjustment_rate")}
name={"default_adjustment_rate"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={0} precision={2} />
</Form.Item>
{InstanceRenderManager({
imex: (
<Form.Item label={t("bodyshop.fields.federal_tax_id")} name="federal_tax_id">
)
})}
<Form.Item
label={t("bodyshop.fields.invoice_state_tax_rate")}
name={["bill_tax_rates", "state_tax_rate"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.invoice_local_tax_rate")}
name={["bill_tax_rates", "local_tax_rate"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
</>
)}
<Form.Item
name={["md_payment_types"]}
label={t("bodyshop.fields.md_payment_types")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>
<Form.Item
name={["md_categories"]}
label={t("bodyshop.fields.md_categories")}
rules={[
{
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>
{HasFeatureAccess({ featureName: "export", bodyshop }) && (
<>
<Form.Item
name={["accountingconfig", "ReceivableCustomField1"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 1 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
name={["accountingconfig", "ReceivableCustomField2"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 2 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
name={["accountingconfig", "ReceivableCustomField3"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 3 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
name={["md_classes"]}
label={t("bodyshop.fields.md_classes")}
rules={[
({ getFieldValue }) => {
return {
required: getFieldValue("enforce_class"),
//message: t("general.validation.required"),
type: "array"
};
}
]}
>
<Select mode="tags" />
</Form.Item>
<Form.Item name={["enforce_class"]} label={t("bodyshop.fields.enforce_class")} valuePropName="checked">
<Switch />
</Form.Item>
{ClosingPeriod.treatment === "on" && (
<Form.Item
name={["accountingconfig", "ClosingPeriod"]}
label={t("bodyshop.fields.closingperiod")} //{t("reportcenter.labels.dates")}
>
<DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} />
</Form.Item>
)}
{ADPPayroll.treatment === "on" && (
<Form.Item name={["accountingconfig", "companyCode"]} label={t("bodyshop.fields.companycode")}>
<Input />
</Form.Item>
)
})}
<Form.Item label={t("bodyshop.fields.state_tax_id")} name="state_tax_id">
<Input />
</Form.Item>
{InstanceRenderManager({
imex: (
<Form.Item
label={t("bodyshop.fields.invoice_federal_tax_rate")}
name={["bill_tax_rates", "federal_tax_rate"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
)}
{ADPPayroll.treatment === "on" && (
<Form.Item name={["accountingconfig", "batchID"]} label={t("bodyshop.fields.batchid")}>
<Input />
</Form.Item>
)
})}
<Form.Item
label={t("bodyshop.fields.invoice_state_tax_rate")}
name={["bill_tax_rates", "state_tax_rate"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.invoice_local_tax_rate")}
name={["bill_tax_rates", "local_tax_rate"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
<Form.Item
name={["md_payment_types"]}
label={t("bodyshop.fields.md_payment_types")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>
<Form.Item
name={["md_categories"]}
label={t("bodyshop.fields.md_categories")}
rules={[
{
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>
<Form.Item name={["enforce_class"]} label={t("bodyshop.fields.enforce_class")} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
name={["accountingconfig", "ReceivableCustomField1"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 1 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
name={["accountingconfig", "ReceivableCustomField2"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 2 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
name={["accountingconfig", "ReceivableCustomField3"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 3 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
name={["md_classes"]}
label={t("bodyshop.fields.md_classes")}
rules={[
({ getFieldValue }) => {
return {
required: getFieldValue("enforce_class"),
//message: t("general.validation.required"),
type: "array"
};
}
]}
>
<Select mode="tags" />
</Form.Item>
{ClosingPeriod.treatment === "on" && (
<Form.Item
name={["accountingconfig", "ClosingPeriod"]}
label={t("bodyshop.fields.closingperiod")} //{t("reportcenter.labels.dates")}
>
<DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} />
</Form.Item>
)}
{ADPPayroll.treatment === "on" && (
<Form.Item name={["accountingconfig", "companyCode"]} label={t("bodyshop.fields.companycode")}>
<Input />
</Form.Item>
)}
{ADPPayroll.treatment === "on" && (
<Form.Item name={["accountingconfig", "batchID"]} label={t("bodyshop.fields.batchid")}>
<Input />
</Form.Item>
)}
</LayoutFormRow>
</FeatureWrapper>
)}
</>
)}
</LayoutFormRow>
<FeatureWrapper featureName="scoreboard" noauth={() => null}>
<LayoutFormRow header={t("bodyshop.labels.scoreboardsetup")} id="scoreboardsetup">
<Form.Item
@@ -823,7 +833,11 @@ export function ShopInfoGeneral({ form, bodyshop }) {
}}
</Form.List>
</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"]}>
{(fields, { add, remove, move }) => {
return (

View File

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

View File

@@ -16,5 +16,6 @@ export default connect(mapStateToProps, mapDispatchToProps)(ShopSubStatus);
export function ShopSubStatus({ bodyshop }) {
const { t } = useTranslation();
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

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

View File

@@ -16,7 +16,7 @@ import {
import { useMutation } from "@apollo/client";
import { useTranslation } from "react-i18next";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { SocketContext, INITIAL_NOTIFICATIONS } from "./useSocket.js";
import { INITIAL_NOTIFICATIONS, SocketContext } from "./useSocket.js";
/**
* Socket Provider - Scenario Notifications / Web Socket related items
@@ -157,7 +157,10 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
auth: { token, bodyshopId: bodyshop.id },
reconnectionAttempts: Infinity,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000
reconnectionDelayMax: 60000,
randomizationFactor: 0.5,
transports: ["websocket", "polling"], // Add this to prefer WebSocket with polling fallback
rememberUpgrade: true
});
socketRef.current = socketInstance;

View File

@@ -312,7 +312,6 @@ export const QUERY_INTAKE_CHECKLIST = gql`
intakechecklist
status
owner {
allow_text_message
id
}
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
status
text
is_system
isoutbound
image
image_path
@@ -77,6 +78,7 @@ export const GET_CONVERSATION_DETAILS = gql`
id
status
text
is_system
isoutbound
image
image_path

View File

@@ -874,7 +874,6 @@ export const QUERY_JOB_CARD_DETAILS = gql`
}
owner {
id
allow_text_message
preferred_contact
tax_number
}
@@ -2071,7 +2070,6 @@ export const QUERY_JOB_CHECKLISTS = gql`
production_vars
owner {
id
allow_text_message
}
bodyshop {
id
@@ -2428,7 +2426,6 @@ export const QUERY_PARTS_QUEUE_CARD_DETAILS = gql`
ownr_ph2
owner {
id
allow_text_message
preferred_contact
tax_number
}

View File

@@ -49,7 +49,6 @@ export const QUERY_OWNER_BY_ID = gql`
owners_by_pk(id: $id) {
id
accountingid
allow_text_message
ownr_addr1
ownr_addr2
ownr_co_nm
@@ -104,7 +103,6 @@ export const QUERY_ALL_OWNERS = gql`
query QUERY_ALL_OWNERS {
owners {
id
allow_text_message
created_at
ownr_addr1
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!]!) {
search_owners(args: { search: $search }, offset: $offset, limit: $limit, order_by: $order) {
id
allow_text_message
created_at
ownr_addr1
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) {
ownerData = job.owner.data;
ownerData.shopid = bodyshop.id;
delete ownerData.allow_text_message;
delete ownerData.preferred_contact;
delete job.ownerid;
} else {

View File

@@ -14,7 +14,7 @@ import { Badge, Button, Divider, Form, Space, Tabs } from "antd";
import Axios from "axios";
import _ from "lodash";
import queryString from "query-string";
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaHardHat, FaRegStickyNote, FaShieldAlt, FaTasks } from "react-icons/fa";
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 JobReconciliationModal from "../../components/job-reconciliation-modal/job-reconciliation.modal.container";
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 JobsConvertButton from "../../components/jobs-convert-button/jobs-convert-button.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 ScheduleJobModalContainer from "../../components/schedule-job-modal/schedule-job-modal.container";
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_JOB_TASKS_PAGINATED } from "../../graphql/tasks.queries.js";
import { insertAuditTrail } from "../../redux/application/application.actions";
@@ -55,9 +58,6 @@ import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { DateTimeFormat } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
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({
bodyshop: selectBodyshop,
@@ -322,11 +322,11 @@ export function JobsDetailPage({
>
<PageHeader
// onBack={() => window.history.back()}
id="job-detail-header"
title={
<Space>
{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>
}
extra={menuExtra}

View File

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

View File

@@ -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 ShopInfoContainer from "../../components/shop-info/shop-info.container";
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 { selectBodyshop } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { HasFeatureAccess } from "../../components/feature-wrapper/feature-wrapper.component";
import ShopTeamsContainer from "../../components/shop-teams/shop-teams.container";
@@ -91,6 +91,16 @@ export function ShopPage({ bodyshop, setSelectedHeader, setBreadcrumbs }) {
children: <ShopCsiConfig />
});
}
if (bodyshop.messagingservicesid) {
// Add Consent Settings tab
items.push({
key: "consent",
label: t("bodyshop.labels.consent_settings"),
children: <ShopInfoConsentComponent bodyshop={bodyshop} />
});
}
return (
<RbacWrapper action="shop:config">
<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.
}
};
case UserActionTypes.SET_SHOP_DETAILS:
return {
...state,
@@ -126,6 +125,7 @@ const userReducer = (state = INITIAL_STATE, action) => {
...state,
imexshopid: action.payload
};
default:
return state;
}

View File

@@ -335,20 +335,12 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
}
try {
InstanceRenderManager({
executeFunction: true,
args: [],
imex: () => {
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 });
}
});
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]]);
}
payload.features?.allAccess === true
? window.$crisp.push(["set", "session:segments", [["allAccess"]]])
: (() => {
@@ -359,6 +351,14 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
);
window.$crisp.push(["set", "session:segments", [["basic", ...featureKeys]]]);
})();
InstanceRenderManager({
executeFunction: true,
args: [],
rome: () => {
window.$zoho.salesiq.visitor.info({ "Shop Name": payload.shopname });
}
});
} catch (error) {
console.warn("Couldnt find $crisp.", error.message);
}

View File

@@ -656,6 +656,7 @@
}
},
"labels": {
"consent_settings": "Phone Number Opt-Out List",
"2tiername": "Name => RO",
"2tiersetup": "2 Tier Setup",
"2tiersource": "Source => RO",
@@ -774,7 +775,6 @@
},
"labels": {
"addtoproduction": "Add Job to Production?",
"allow_text_message": "Permission to Text?",
"checklist": "Checklist",
"printpack": "Job Intake Print Pack",
"removefromproduction": "Remove Job from Production?"
@@ -1230,7 +1230,11 @@
"fcm": "You must allow notification permissions to have real time messaging. Click to try again.",
"notfound": "No record was found.",
"sizelimit": "The selected items exceed the size limit.",
"submit-for-testing": "Error submitting Job for testing."
"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": {
"contract": "CC Contract",
@@ -2031,9 +2035,10 @@
"stands": "Stands",
"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",
"deleteintake": "Delete Intake Checklist",
"deletewatchers": "Remove Watchers before deleting this Job.",
"deliverchecklist": "Deliver Checklist",
"difference": "Difference",
"diskscan": "Scan Disk for Estimates",
@@ -2301,8 +2306,10 @@
"productionlist": "Production Board - List",
"readyjobs": "Ready Jobs",
"recent": "Recent Items",
"remoteassist": "Remote Assist",
"reportcenter": "Report Center",
"rescueme": "Rescue me!",
"rescueme": "Rescue Me!",
"rescuemezoho": "Remote Me In!",
"schedule": "Schedule",
"scoreboard": "Scoreboard",
"search": {
@@ -2377,7 +2384,8 @@
"errors": {
"invalidphone": "The phone number is invalid. Unable to open 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": {
"addlabel": "Add a label to this conversation.",
@@ -2393,7 +2401,8 @@
"selectmedia": "Select Media",
"sentby": "Sent by {{by}} at {{time}}",
"typeamessage": "Send a message...",
"unarchive": "Unarchive"
"unarchive": "Unarchive",
"no_consent": "Opted-out"
},
"render": {
"conversation_list": "Conversation List"
@@ -2518,7 +2527,6 @@
"fields": {
"accountingid": "Accounting ID",
"address": "Address",
"allow_text_message": "Permission to Text?",
"name": "Name",
"note": "Owner Note",
"ownr_addr1": "Address",
@@ -3099,6 +3107,7 @@
"credits_not_received_date_vendorid": "Credits not Received by Vendor",
"csi": "CSI Responses",
"customer_list": "Customer List",
"customer_list_excel": "Customer List - Excel",
"cycle_time_analysis": "Cycle Time Analysis",
"estimates_written_converted": "Estimates Written/Converted",
"estimator_detail": "Jobs by Estimator (Detail)",
@@ -3862,6 +3871,18 @@
"validation": {
"unique_vendor_name": "You must enter a unique vendor name."
}
},
"consent": {
"phone_number": "Phone Number",
"associated_owners": "Associated Owners",
"created_at": "Opt-Out Date",
"no_owners": "No Associated Owners",
"phone_1": "Phone 1",
"phone_2": "Phone 2",
"text_body": "Users can opt out of receiving SMS messages by replying with keywords such as STOP, UNSUBSCRIBE, CANCEL, END, QUIT, STOPALL, REVOKE and OPTOUT. To opt back in, users can reply with START, YES, or UNSTOP. Even after opting out, users can still send messages to us, which will be received and processed as needed. Ensure customers are informed to reply with these keywords to manage their messaging preferences. After opting out, users receive a confirmation message and will not receive further messages until they opt back in."
},
"settings": {
"title": "Phone Number Opt-Out List"
}
}
}

View File

@@ -656,6 +656,7 @@
}
},
"labels": {
"consent_settings": "",
"2tiername": "",
"2tiersetup": "",
"2tiersource": "",
@@ -774,7 +775,6 @@
},
"labels": {
"addtoproduction": "",
"allow_text_message": "",
"checklist": "",
"printpack": "",
"removefromproduction": ""
@@ -1230,7 +1230,11 @@
"fcm": "",
"notfound": "",
"sizelimit": "",
"submit-for-testing": ""
"submit-for-testing": "",
"sub_status": {
"expired": "",
"trial-expired": ""
}
},
"itemtypes": {
"contract": "",
@@ -2034,6 +2038,7 @@
"deleteconfirm": "",
"deletedelivery": "",
"deleteintake": "",
"deletewatchers": "",
"deliverchecklist": "",
"difference": "",
"diskscan": "",
@@ -2301,8 +2306,10 @@
"productionlist": "",
"readyjobs": "",
"recent": "",
"remoteassist": "",
"reportcenter": "",
"rescueme": "",
"rescuemezoho": "",
"schedule": "Programar",
"scoreboard": "",
"search": {
@@ -2377,7 +2384,8 @@
"errors": {
"invalidphone": "",
"noattachedjobs": "",
"updatinglabel": ""
"updatinglabel": "",
"no_consent": ""
},
"labels": {
"addlabel": "",
@@ -2393,7 +2401,8 @@
"selectmedia": "",
"sentby": "",
"typeamessage": "Enviar un mensaje...",
"unarchive": ""
"unarchive": "",
"no_consent": ""
},
"render": {
"conversation_list": ""
@@ -2498,7 +2507,8 @@
},
"tooltips": {
"job-watchers": "",
"not-employee": ""
"not-employee": "",
"not-employee-notifications": ""
}
},
"owner": {
@@ -2519,7 +2529,6 @@
"fields": {
"accountingid": "",
"address": "Dirección",
"allow_text_message": "Permiso de texto?",
"name": "Nombre",
"note": "",
"ownr_addr1": "Dirección",
@@ -3100,6 +3109,7 @@
"credits_not_received_date_vendorid": "",
"csi": "",
"customer_list": "",
"customer_list_excel": "",
"cycle_time_analysis": "",
"estimates_written_converted": "",
"estimator_detail": "",
@@ -3863,6 +3873,18 @@
"validation": {
"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": {
"consent_settings": "",
"2tiername": "",
"2tiersetup": "",
"2tiersource": "",
@@ -774,7 +775,6 @@
},
"labels": {
"addtoproduction": "",
"allow_text_message": "",
"checklist": "",
"printpack": "",
"removefromproduction": ""
@@ -1230,7 +1230,11 @@
"fcm": "",
"notfound": "",
"sizelimit": "",
"submit-for-testing": ""
"submit-for-testing": "",
"sub_status": {
"expired": "",
"trial-expired": ""
}
},
"itemtypes": {
"contract": "",
@@ -2034,6 +2038,7 @@
"deleteconfirm": "",
"deletedelivery": "",
"deleteintake": "",
"deletewatchers": "",
"deliverchecklist": "",
"difference": "",
"diskscan": "",
@@ -2301,8 +2306,10 @@
"productionlist": "",
"readyjobs": "",
"recent": "",
"remoteassist": "",
"reportcenter": "",
"rescueme": "",
"rescuemezoho": "",
"schedule": "Programme",
"scoreboard": "",
"search": {
@@ -2377,7 +2384,8 @@
"errors": {
"invalidphone": "",
"noattachedjobs": "",
"updatinglabel": ""
"updatinglabel": "",
"no_consent": ""
},
"labels": {
"addlabel": "",
@@ -2393,7 +2401,8 @@
"selectmedia": "",
"sentby": "",
"typeamessage": "Envoyer un message...",
"unarchive": ""
"unarchive": "",
"no_consent": ""
},
"render": {
"conversation_list": ""
@@ -2498,7 +2507,8 @@
},
"tooltips": {
"job-watchers": "",
"not-employee": ""
"not-employee": "",
"not-employee-notifications": ""
}
},
"owner": {
@@ -2519,7 +2529,6 @@
"fields": {
"accountingid": "",
"address": "Adresse",
"allow_text_message": "Autorisation de texte?",
"name": "Prénom",
"note": "",
"ownr_addr1": "Adresse",
@@ -3100,6 +3109,7 @@
"credits_not_received_date_vendorid": "",
"csi": "",
"customer_list": "",
"customer_list_excel": "",
"cycle_time_analysis": "",
"estimates_written_converted": "",
"estimator_detail": "",
@@ -3863,6 +3873,18 @@
"validation": {
"unique_vendor_name": ""
}
},
"consent": {
"phone_number": "Phone Number",
"associated_owners": "Associated Owners",
"created_at": "Opt-Out Date",
"no_owners": "No Associated Owners",
"phone_1": "Phone 1",
"phone_2": "Phone 2",
"text_body": ""
},
"settings": {
"title": ""
}
}
}

View File

@@ -1,6 +1,5 @@
import { Tooltip } from "antd";
import dayjs from "../utils/day";
import React from "react";
export function DateFormatter(props) {
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"
},
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: {
title: 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")
},
group: "purchases"
},
}
}
: {}),
...(!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

@@ -1035,6 +1035,7 @@
- use_fippa
- use_paint_scale_data
- uselocalmediaserver
- external_shop_id
- website
- workingdays
- zip_post
@@ -1130,6 +1131,7 @@
- use_fippa
- use_paint_scale_data
- uselocalmediaserver
- external_shop_id
- website
- workingdays
- zip_post
@@ -2681,6 +2683,9 @@
- active:
_eq: true
allow_aggregations: true
- table:
name: integration_log
schema: public
- table:
name: inventory
schema: public
@@ -4739,6 +4744,7 @@
- id
- image
- image_path
- is_system
- isoutbound
- msid
- read
@@ -5861,6 +5867,32 @@
template_engine: Kriti
url: '{{$base_url}}/opensearch'
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:
name: phonebook
schema: public

View File

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

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" add column "enforce_sms_consent" boolean
null;

View File

@@ -0,0 +1 @@
DROP TABLE "public"."phone_number_consent";

View File

@@ -0,0 +1,2 @@
CREATE TABLE "public"."phone_number_consent" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "bodyshopid" uuid NOT NULL, "phone_number" text NOT NULL, "consent_status" boolean NOT NULL, "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now(), "consent_updated_at" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("id") , FOREIGN KEY ("bodyshopid") REFERENCES "public"."bodyshops"("id") ON UPDATE restrict ON DELETE restrict, UNIQUE ("id"), UNIQUE ("bodyshopid", "phone_number"));
CREATE EXTENSION IF NOT EXISTS pgcrypto;

View File

@@ -0,0 +1 @@
DROP TABLE "public"."phone_number_consent_history";

View File

@@ -0,0 +1,2 @@
CREATE TABLE "public"."phone_number_consent_history" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "phone_number_consent_id" uuid NOT NULL, "old_value" boolean NOT NULL, "new_value" boolean NOT NULL, "reason" text NOT NULL, "changed_at" timestamptz NOT NULL DEFAULT now(), "changed_by" text NOT NULL, PRIMARY KEY ("id") , FOREIGN KEY ("phone_number_consent_id") REFERENCES "public"."phone_number_consent"("id") ON UPDATE restrict ON DELETE restrict, UNIQUE ("id"));
CREATE EXTENSION IF NOT EXISTS pgcrypto;

View File

@@ -0,0 +1 @@
alter table "public"."phone_number_consent_history" alter column "old_value" set not null;

View File

@@ -0,0 +1 @@
alter table "public"."phone_number_consent_history" alter column "old_value" drop not null;

View File

@@ -0,0 +1,3 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- DROP table "public"."phone_number_consent_history";

View File

@@ -0,0 +1 @@
DROP table "public"."phone_number_consent_history";

View File

@@ -0,0 +1,2 @@
alter table "public"."phone_number_consent" alter column "consent_status" drop not null;
alter table "public"."phone_number_consent" add column "consent_status" bool;

View File

@@ -0,0 +1 @@
alter table "public"."phone_number_consent" drop column "consent_status" cascade;

View File

@@ -0,0 +1,3 @@
alter table "public"."phone_number_consent" alter column "consent_updated_at" set default now();
alter table "public"."phone_number_consent" alter column "consent_updated_at" drop not null;
alter table "public"."phone_number_consent" add column "consent_updated_at" timestamptz;

View File

@@ -0,0 +1 @@
alter table "public"."phone_number_consent" drop column "consent_updated_at" cascade;

View File

@@ -0,0 +1 @@
alter table "public"."phone_number_opt_out" rename to "phone_number_consent";

View File

@@ -0,0 +1 @@
alter table "public"."phone_number_consent" rename to "phone_number_opt_out";

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" alter column "enforce_sms_consent" drop not null;
alter table "public"."bodyshops" add column "enforce_sms_consent" bool;

View File

@@ -0,0 +1 @@
alter table "public"."bodyshops" drop column "enforce_sms_consent" cascade;

View File

@@ -0,0 +1 @@
DROP TABLE "public"."integration_log";

View File

@@ -0,0 +1,18 @@
CREATE TABLE "public"."integration_log" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now(), "bodyshopid" uuid NOT NULL, "email" text NOT NULL, "jobid" uuid, "billid" uuid, "paymentid" uuid, "method" text NOT NULL, "name" text NOT NULL, "status" text NOT NULL, "platform" text NOT NULL, PRIMARY KEY ("id") , FOREIGN KEY ("bodyshopid") REFERENCES "public"."bodyshops"("id") ON UPDATE restrict ON DELETE restrict, FOREIGN KEY ("email") REFERENCES "public"."users"("email") ON UPDATE restrict ON DELETE restrict, FOREIGN KEY ("billid") REFERENCES "public"."bills"("id") ON UPDATE restrict ON DELETE set null, FOREIGN KEY ("jobid") REFERENCES "public"."jobs"("id") ON UPDATE restrict ON DELETE restrict, FOREIGN KEY ("paymentid") REFERENCES "public"."payments"("id") ON UPDATE restrict ON DELETE set null);
CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"()
RETURNS TRIGGER AS $$
DECLARE
_new record;
BEGIN
_new := NEW;
_new."updated_at" = NOW();
RETURN _new;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "set_public_integration_log_updated_at"
BEFORE UPDATE ON "public"."integration_log"
FOR EACH ROW
EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"();
COMMENT ON TRIGGER "set_public_integration_log_updated_at" ON "public"."integration_log"
IS 'trigger to set value of column "updated_at" to current timestamp on row update';
CREATE EXTENSION IF NOT EXISTS pgcrypto;

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."bodyshops" add column "we_profile_id" text
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" add column "we_profile_id" text
null;

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" rename column "parts_management_key" to "we_profile_id";
alter table "public"."bodyshops" drop constraint "bodyshops_we_profile_id_key";

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" add constraint "bodyshops_we_profile_id_key" unique ("we_profile_id");
alter table "public"."bodyshops" rename column "we_profile_id" to "parts_management_key";

View File

@@ -0,0 +1 @@
alter table "public"."bodyshops" rename column "external_shop_id" to "parts_management_key";

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