Compare commits

..

126 Commits

Author SHA1 Message Date
Dave Richer
c4631f50e5 feature/IO-3214-Job-Status-Card-Extension - PR Notes/Package Updates 2025-05-08 11:25:44 -04:00
Dave Richer
110fad2abc feature/IO-3214-Job-Status-Card-Extension - Complete 2025-05-06 17:08:46 -04:00
Dave Richer
b7456cecd4 release/2025-05-09 - Restore GraphQL Dep 2025-05-06 14:55:18 -04:00
Dave Richer
84db1fe81b Merged in feature/IO-3225-Notifications-1.5 (pull request #2300)
Feature/IO-3225 Notifications 1.5 into Release/2025-05-09
2025-05-06 18:20:20 +00:00
Dave Richer
b539111be8 feature/IO-3225-Notifications-1.5: Final Refactoring / Optimization 2025-05-06 14:19:22 -04:00
Dave Richer
8a8bc5a6ed feature/IO-3225-Notifications-1.5: Final Refactoring / Optimization 2025-05-06 13:53:40 -04:00
Dave Richer
020db91105 feature/IO-3225-Notifications-1.5: checkpoint 2025-05-06 13:38:42 -04:00
Dave Richer
1dd28af752 feature/IO-3225-Notifications-1.5: Package Updates / Maintenance 2025-05-06 12:58:21 -04:00
Dave Richer
5ba192eee0 feature/IO-3225-Notifications-1.5: Finish 2025-05-05 17:06:23 -04:00
Dave Richer
8109a12898 feature/IO-3225-Notifications-1.5: DB Changes 2025-05-05 15:02:44 -04:00
Dave Richer
2deb7fd520 feature/IO-3225-Notifications-1.5: DB Changes 2025-05-05 13:57:25 -04:00
Dave Richer
f6cd136679 feature/IO-3225-Notifications-1.5: Packages 2025-05-05 13:16:57 -04:00
Dave Richer
e50cb86296 release/2025-04-25 - Update DD, trying again 2025-05-02 13:45:14 -04:00
Dave Richer
a5a01c44fa release/2025-04-25 - Remove DD 2025-04-30 14:07:01 -04:00
Dave Richer
947e0705e4 release/2025-04-25 - Update/Restore DD for test purposes.4 2025-04-30 11:37:21 -04:00
Dave Richer
aa8a6a837d release/2025-04-25: remove body-parser reference from pay-all.js 2025-04-30 11:30:25 -04:00
Dave Richer
5db440fc9c release/2025-04-25: remove body-parser reference from misc routes 2025-04-30 11:28:15 -04:00
Dave Richer
c299b9376a release/2025-04-25: revert body parser, remove DD 2025-04-30 10:26:38 -04:00
Dave Richer
e5d530ea3e release/2025-04-25: Update Backend Packages 2025-04-28 17:15:13 -04:00
Dave Richer
6da9850946 release/2025-04-25: Update Sentry 2025-04-28 13:03:02 -04:00
Dave Richer
f62609f60c release/2025-04-25: revert body parser to discrete package 2025-04-28 12:58:27 -04:00
Dave Richer
b2d8c66e5b release/2025-04-25: clean 2025-04-28 12:17:40 -04:00
Dave Richer
3c4ed3ba0c release/2025-04-25: Patch Updates to packages 2025-04-26 12:16:46 -04:00
Dave Richer
2e7f827c3f release/2025-04-25: Patch Updates to packages 2025-04-26 11:18:39 -04:00
Patrick Fic
dc82b39dc8 Remove crisp status reporter. 2025-04-25 20:27:07 -07:00
Patrick Fic
bdb741caf8 Merged in feature/IO-3223-canny (pull request #2288)
IO-3223 Add Canny for feature request and change log.

Approved-by: Dave Richer
2025-04-25 21:12:43 +00:00
Dave Richer
f50b198c21 feature/IO-3223-canny - Small syntactic update 2025-04-25 17:12:18 -04:00
Dave Richer
3495326de3 feature/IO-3223-canny - Merge release / fix conflicts 2025-04-25 17:06:44 -04:00
Patrick Fic
b5973085e7 IO-3223 Add Canny for feature request and change log. 2025-04-25 14:02:40 -07:00
Dave Richer
8687214420 release/2025-04-25 - update handleInvoiceBasedPayment.test.js 2025-04-25 11:58:47 -04:00
Dave Richer
d61b89a1e5 release/2025-04-25 - Add logging around handleInvoiceBasePayment paymentResponse, toned logs down. fixed issue in paymentResponseResults 2025-04-25 11:54:36 -04:00
Allan Carr
468b42abd2 Merged in feature/IO-3220-VPB-Popup (pull request #2284)
IO-3220 VPB Board Settings Popup

Approved-by: Dave Richer
2025-04-25 14:59:13 +00:00
Allan Carr
fc03e5f983 IO-3220 VPB Board Settings Popup
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-24 13:32:27 -07:00
Allan Carr
c4742e38ea Merged in feature/IO-3213-Hit-and-Run-Toggle (pull request #2280)
IO-3213 Hit and Run Toggle

Approved-by: Dave Richer
2025-04-24 15:41:41 +00:00
Allan Carr
99e1adbe13 Merge branch 'release/2025-04-25' into feature/IO-3213-Hit-and-Run-Toggle
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>

# Conflicts:
#	client/src/components/jobs-detail-general/jobs-detail-general.component.jsx
2025-04-24 08:42:32 -07:00
Allan Carr
eb5c797a43 Merged in feature/IO-3215-Employee-Assignment-Timeticket-Modal (pull request #2279)
IO-3215 Employee Assignment Timeticket Modal

Approved-by: Dave Richer
2025-04-24 15:34:42 +00:00
Allan Carr
0595c5545e Merged in feature/IO-3212-ACV-Amount (pull request #2281)
IO-3212 ACV Amount

Approved-by: Dave Richer
2025-04-24 15:33:41 +00:00
Allan Carr
55944257aa IO-3212 ACV Amount
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-23 13:16:49 -07:00
Allan Carr
03241778fa IO-3212 ACV Amount
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-23 13:10:42 -07:00
Allan Carr
555b81fb14 IO-3213 Hit and Run Toggle
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-23 12:04:29 -07:00
Allan Carr
a56b720e09 IO-3215 Employee Assignment Timeticket Modal
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-23 09:51:44 -07:00
Allan Carr
b89eede164 Merged in feature/IO-3164-Schedule-Completion-Business-Days (pull request #2277)
IO-3164 Schedule Completion Business Days

Approved-by: Dave Richer
2025-04-22 13:48:43 +00:00
Allan Carr
c21cc8d6b9 IO-3164 Schedule Completion Business Days
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-21 12:27:33 -07:00
Dave Richer
d02a6bc197 Merged in feature/IO-2282-VSSTA-Integration (pull request #2270)
[DO NOT MERGE] Feature/IO-2282 VSSTA Integration into release/2025-04-25
2025-04-21 17:00:07 +00:00
Dave Richer
360c1ce82d feature/IO-2282-VSSTA-Integration 2025-04-21 12:57:00 -04:00
Allan Carr
a7ef02976c Merged in feature/IO-3200-Extended-Crisp-Segments (pull request #2276)
IO-3200 Extended Crisp Segments for BASIC/LITE

Approved-by: Dave Richer
2025-04-21 16:54:31 +00:00
Allan Carr
6a9e36ea4d IO-3200 Extended Crisp Segments for BASIC/LITE
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-17 16:20:34 -07:00
Allan Carr
5ebca3ff06 Merged in feature/IO-3210-Podium-Datapump (pull request #2275)
IO-3210 Podium Datapump CRON Trigger
2025-04-17 19:42:05 +00:00
Allan Carr
37d4c0a40f IO-3210 Podium Datapump CRON Trigger
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-17 12:42:19 -07:00
Allan Carr
1969a92226 Merged in feature/IO-3210-Podium-Datapump (pull request #2273)
IO-3210 Podium Datapump

Approved-by: Dave Richer
2025-04-17 17:40:59 +00:00
Allan Carr
8840ffc9ba IO-3210 Product Fruits Insurance Company Add Button ID
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-17 10:03:08 -07:00
Allan Carr
19e42ef397 IO-3210 Podium Datapump
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-16 17:52:22 -07:00
Allan Carr
c7eb026986 Merged in feature/IO-3190-Quick-Intake-Schedule-Event (pull request #2271)
IO-3190 Quick Intake Schedule Event

Approved-by: Dave Richer
2025-04-16 20:16:41 +00:00
Allan Carr
b0dcd3618e IO-3190 Quick Intake Schedule Event
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-16 13:00:01 -07:00
Allan Carr
5f23f135f2 Merged in feature/IO-3187-Admin-Enhancements (pull request #2269)
IO-3187 Admin Enhancements

Approved-by: Dave Richer
2025-04-16 19:10:03 +00:00
Allan Carr
159ee7364d IO-3187 Admin Enhancements
add BCC

Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-15 22:08:21 -07:00
Dave Richer
aa6ad109c9 feature/IO-3187-Admin-Enhancements - Minor cleanup 2025-04-15 14:21:28 -04:00
Dave Richer
f2a896d568 feature/IO-3187-Admin-Enhancements - Minor cleanup 2025-04-15 14:02:29 -04:00
Dave Richer
546ebba0bd feature/IO-3187-Admin-Enhancements - Minor cleanup 2025-04-15 13:57:50 -04:00
Dave Richer
0e75f54d6e feature/IO-2282-VSSTA-Integration: - doc blocks / cleanup 2025-04-15 13:39:34 -04:00
Dave Richer
30f34a17ea feature/IO-2282-VSSTA-Integration: - doc blocks / cleanup 2025-04-15 13:20:07 -04:00
Dave Richer
6035d94404 feature/IO-2282-VSSTA-Integration: - doc blocks / cleanup 2025-04-15 13:05:42 -04:00
Dave Richer
0b7a23d555 feature/IO-2282-VSSTA-Integration: - include some tests for media utils 2025-04-15 13:02:54 -04:00
Dave Richer
91fe1f4af9 feature/IO-2282-VSSTA-Integration: - Finish Integration 2025-04-15 12:55:38 -04:00
Dave Richer
f09cb7b247 feature/IO-2282-VSSTA-Integration: - Finish Integration 2025-04-15 12:40:33 -04:00
Dave Richer
35a7222f5e feature/IO-2282-VSSTA-Integration: - checkpoint 2025-04-15 11:29:44 -04:00
Dave Richer
d444821cf7 feature/IO-2282-VSSTA-Integration: - checkpoint 2025-04-15 10:46:49 -04:00
Allan Carr
b5cb520944 IO-3187 Admin Enhancements
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-14 17:07:57 -07:00
Allan Carr
6814a3bc33 Merge branch 'master-AIO' into feature/IO-3187-Admin-Enhancements
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-14 16:57:20 -07:00
Patrick Fic
19c2b19abc Merged in feature/IO-3066-partner-refresh (pull request #2268)
IO-3066 Call partner refresh on shop change.
2025-04-14 22:32:44 +00:00
Patrick Fic
22b011139d IO-3066 Call partner refresh on shop change. 2025-04-14 15:29:45 -07:00
Dave Richer
5b30daefe5 Merge remote-tracking branch 'origin/master-AIO' into feature/IO-2282-VSSTA-Integration 2025-04-14 11:03:29 -04:00
Dave Richer
e015d3574a Merged in release/2025-04-11 (pull request #2267)
Release/2025-04-11 into master-AIO - IO-2769, IO-2885, IO-3045, IO-3066, IO-3193, IO-3198, IO-3202
2025-04-12 01:27:08 +00:00
Dave Richer
60140902d4 Merged in feature/IO-3045-Product-Fruits-Tours-Modifications (pull request #2265)
feature/IO-3045-Product-Fruits-Tours-Modifications - Add in roles prop on Product fruits, tie the usage of it to a new db bodyshop var tours_enabled.
2025-04-10 16:28:21 +00:00
Dave Richer
84f41b2c11 feature/IO-3045-Product-Fruits-Tours-Modifications - Add in roles prop on Product fruits, tie the usage of it to a new db bodyshop var tours_enabled. 2025-04-10 12:27:54 -04:00
Dave Richer
e8b9fcbc6e feature/IO-2282-VSSTA-Integration:
- Clean up imgproxy-media.js
2025-04-10 09:37:31 -04:00
Dave Richer
5adf591670 feature/IO-2282-VSSTA-Integration:
- Clean up imgproxy-media.js
2025-04-10 09:27:49 -04:00
Dave Richer
f55764e859 feature/IO-2282-VSSTA-Integration:
- Boilerplate in new route
- Fix issues with imgproxy
- Clean up imgproxy
2025-04-09 14:56:49 -04:00
Dave Richer
282fa787a9 Merged in feature/IO-2769-Job-Totals-Testing (pull request #2263)
feature/IO-2769-Job-Totals-Testing - packages, final update
2025-04-09 16:53:04 +00:00
Dave Richer
037efff81c feature/IO-2769-Job-Totals-Testing - packages, final update 2025-04-09 12:52:23 -04:00
Patrick Fic
e26eb17d09 Merged in feature/IO-3066-ems-parts-order-data (pull request #2262)
IO-3066 Add additional CIECA fields for new partner

Approved-by: Patrick Fic
2025-04-09 16:34:42 +00:00
Patrick Fic
fbea9fde27 IO-3066 Add additional CIECA fields for new partner 2025-04-09 09:33:37 -07:00
Dave Richer
ce7cf6bdbe Merged in feature/IO-2769-Job-Totals-Testing (pull request #2260)
feature/IO-2769-Job-Totals-Testing - updated tests
2025-04-09 15:19:09 +00:00
Dave Richer
2c47e5d852 feature/IO-2769-Job-Totals-Testing - updated tests 2025-04-09 11:18:09 -04:00
Dave Richer
a6f809b20a Merged in feature/IO-2769-Job-Totals-Testing (pull request #2258)
feature/IO-2769-Job-Totals-Testing - non-related
2025-04-08 22:14:52 +00:00
Dave Richer
2bcad68351 feature/IO-2769-Job-Totals-Testing - non-related 2025-04-08 18:14:20 -04:00
Dave Richer
6b1b393804 Merged in feature/IO-2769-Job-Totals-Testing (pull request #2256)
Feature/IO-2769 Job Totals Testing
2025-04-08 22:11:26 +00:00
Dave Richer
c5181d1c5d feature/IO-2769-Job-Totals-Testing - non-related 2025-04-08 18:10:28 -04:00
Dave Richer
e33ff2a45d feature/IO-2769-Job-Totals-Testing - cleanup 2025-04-08 17:04:50 -04:00
Allan Carr
9eb77964db Merged in feature/IO-3202-HasFeatureAccess-Boolean (pull request #2250)
IO-3202 HasFeatureAccess Boolean

Approved-by: Dave Richer
2025-04-08 17:17:50 +00:00
Allan Carr
0a68d2791d IO-3202 HasFeatureAccess Boolean
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-08 09:51:52 -07:00
Dave Richer
11928d9a7e Merged in feature/IO-2769-Job-Totals-Testing (pull request #2253)
Feature/IO-2769 Job Totals Testing
2025-04-08 16:23:34 +00:00
Dave Richer
c169bb5d5d feature/IO-2769-Job-Totals-Testing - update end point 2025-04-08 12:19:33 -04:00
Dave Richer
3cc4f1c63e feature/IO-2769-Job-Totals-Testing - fix typo 2025-04-08 12:00:43 -04:00
Dave Richer
5237b1d535 Merged in feature/IO-2769-Job-Totals-Testing (pull request #2251)
feature/IO-2769-Job-Totals-Testing
2025-04-08 15:47:09 +00:00
Dave Richer
cd56c50cf9 feature/IO-2769-Job-Totals-Testing 2025-04-08 11:46:16 -04:00
Dave Richer
a18ce18d72 feature/IO-2769-Job-Totals-Testing 2025-04-08 11:42:27 -04:00
Allan Carr
3691d32aaa IO-3202 HasFeatureAccess Boolean
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-07 17:36:39 -07:00
Dave Richer
5f66488410 release/2025-04-11 - Remove unused constant from server.js 2025-04-07 13:06:47 -04:00
Dave Richer
d1be7f6e09 Merged in test-AIO (pull request #2249)
Test AIO
2025-04-07 17:04:04 +00:00
Dave Richer
44f02f28a6 Merged in release/2025-04-11 (pull request #2248)
release/2025-04-11 - Add Ivana to Usage Report
2025-04-07 17:03:00 +00:00
Dave Richer
6d33622b4e release/2025-04-11 - Add Ivana to Usage Report 2025-04-07 13:01:23 -04:00
Dave Richer
f8b8e23ef4 Merged in release/2025-04-11 (pull request #2247)
release/2025-04-11 - Fix Packages
2025-04-07 16:12:42 +00:00
Dave Richer
db09d09428 release/2025-04-11 - Fix Packages 2025-04-07 12:11:53 -04:00
Dave Richer
451820a67c Merged in release/2025-04-11 (pull request #2246)
Release/2025 04 11
2025-04-04 18:19:46 +00:00
Dave Richer
ba0ce5027e Merged in feature/IO-2769-Job-Totals-Testing (pull request #2245)
feature/IO-2769-Job-Totals-testing: Setup testing method for job totals

Approved-by: Patrick Fic
2025-04-04 18:18:51 +00:00
Dave Richer
f777d26cc1 feature/IO-2769-Job-Totals-testing: Allow for both american and canadian capture + tests 2025-04-04 14:15:30 -04:00
Dave Richer
1463037878 feature/IO-2769-Job-Totals-testing: Allow for both american and canadian capture + tests 2025-04-04 13:54:53 -04:00
Dave Richer
7ddec0bb0f feature/IO-2769-Job-Totals-testing: Allow for both american and canadian capture + tests 2025-04-04 13:49:21 -04:00
Dave Richer
51c2d3351a feature/IO-2769-Job-Totals-testing: Allow for both american and canadian capture + tests 2025-04-04 13:47:38 -04:00
Dave Richer
8323fa6696 feature/IO-2769-Job-Totals-testing: Allow for both american and canadian capture + tests 2025-04-04 13:45:45 -04:00
Dave Richer
27a3932c08 feature/IO-2769-Job-Totals-testing: Allow for both american and canadian capture + tests 2025-04-04 13:43:19 -04:00
Dave Richer
add88659a4 feature/IO-2769-Job-Totals-testing: Setup testing method for job totals 2025-04-04 13:23:33 -04:00
Dave Richer
320ad065d0 feature/IO-2769-Job-Totals-testing: Setup testing method for job totals 2025-04-04 13:19:44 -04:00
Dave Richer
a9bc51949a Merged in release/2025-04-11 (pull request #2244)
feature/IO-3198-Seperate-Socket-Provider-useSocket
2025-04-03 17:12:19 +00:00
Dave Richer
39d1397221 Merged in feature/IO-3198-Seperate-Socket-Provider-useSocket (pull request #2243)
feature/IO-3198-Seperate-Socket-Provider-useSocket
2025-04-03 17:11:55 +00:00
Dave Richer
f3e2a83bab Merged in release/2025-04-11 (pull request #2242)
IO-3193 Reconciliation Zero Part Price or Qty
2025-04-03 16:37:40 +00:00
Dave Richer
c03d45b3fc Merged in release/2025-04-11 (pull request #2240)
Release/2025 04 11
2025-04-02 18:15:36 +00:00
Dave Richer
24d47ae1c5 Merged in release/2025-03-28 (pull request #2237)
Release/2025 03 28
2025-04-02 15:39:16 +00:00
Dave Richer
1b8be56c15 Merged in release/2025-03-28 (pull request #2234)
Release/2025-03-28 into test-AIO - IO-3181
2025-03-28 16:18:08 +00:00
Dave Richer
2b26db78eb Merged in release/2025-03-28 (pull request #2232)
IO-3187 Admin Enhancements
2025-03-27 14:04:43 +00:00
Dave Richer
c2d96922c8 Merged in release/2025-03-28 (pull request #2230)
IO-3176 IntelliPay Payment Mapping
2025-03-26 18:24:26 +00:00
Dave Richer
70b4ec7948 Merged in release/2025-03-28 (pull request #2228)
release/2025-03-28 - Modify vite config
2025-03-25 20:53:36 +00:00
Dave Richer
a3ec364034 Merged in release/2025-03-28 (pull request #2227)
release/2025-03-28 - Modify vite config
2025-03-25 20:48:25 +00:00
Dave Richer
e1728b275b Merged in release/2025-03-28 (pull request #2226)
release/2025-03-28 - Package locks
2025-03-25 20:38:36 +00:00
Dave Richer
10d55df461 Merged in release/2025-03-28 (pull request #2224)
Release/2025 03 28
2025-03-25 19:07:28 +00:00
126 changed files with 8768 additions and 7488 deletions

2
.gitignore vendored
View File

@@ -127,4 +127,6 @@ vitest-report*/
vitest-coverage/ vitest-coverage/
*.vitest.log *.vitest.log
test-output.txt test-output.txt
server/job/test/fixtures
.github

View File

@@ -56,4 +56,5 @@ COPY . .
EXPOSE 4000 9229 EXPOSE 4000 9229
# Start the application # Start the application
CMD ["nodemon", "--legacy-watch", "--inspect=0.0.0.0:9229", "server.js"] RUN echo "Starting the application..."
CMD ["nodemon", "--ignore", "./server/job/test/fixtures", "--legacy-watch", "--inspect=0.0.0.0:9229", "server.js"]

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,8 @@
"license": "ISC", "license": "ISC",
"description": "", "description": "",
"dependencies": { "dependencies": {
"express": "^4.21.1", "express": "^5.1.0",
"mailparser": "^3.7.1", "mailparser": "^3.7.2",
"node-fetch": "^3.3.2" "node-fetch": "^3.3.2"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -74,50 +74,8 @@
})(); })();
</script> </script>
<% } %> <% } %>
<script> <script>!function(w,d,i,s){function l(){if(!d.getElementById(i)){var f=d.getElementsByTagName(s)[0],e=d.createElement(s);e.type="text/javascript",e.async=!0,e.src="https://canny.io/sdk.js",f.parentNode.insertBefore(e,f)}}if("function"!=typeof w.Canny){var c=function(){c.q.push(arguments)};c.q=[],w.Canny=c,"complete"===d.readyState?l():w.attachEvent?w.attachEvent("onload",l):w.addEventListener("load",l,!1)}}(window,document,"canny-jssdk","script");</script>
!(function () {
"use strict";
var e = [
"debug",
"destroy",
"do",
"help",
"identify",
"is",
"off",
"on",
"ready",
"render",
"reset",
"safe",
"set"
];
if (window.noticeable) console.warn("Noticeable SDK code snippet loaded more than once");
else {
var n = (window.noticeable = window.noticeable || []);
function t(e) {
return function () {
var t = Array.prototype.slice.call(arguments);
return t.unshift(e), n.push(t), n;
};
}
!(function () {
for (var o = 0; o < e.length; o++) {
var r = e[o];
n[r] = t(r);
}
})(),
(function () {
var e = document.createElement("script");
(e.async = !0), (e.src = "https://sdk.noticeable.io/l.js");
var n = document.head;
n.insertBefore(e, n.firstChild);
})();
}
})();
</script>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

2757
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,22 +9,22 @@
"proxy": "http://localhost:4000", "proxy": "http://localhost:4000",
"dependencies": { "dependencies": {
"@ant-design/pro-layout": "^7.22.4", "@ant-design/pro-layout": "^7.22.4",
"@apollo/client": "^3.13.5", "@apollo/client": "^3.13.6",
"@emotion/is-prop-valid": "^1.3.1", "@emotion/is-prop-valid": "^1.3.1",
"@fingerprintjs/fingerprintjs": "^4.6.1", "@fingerprintjs/fingerprintjs": "^4.6.1",
"@firebase/analytics": "^0.10.12", "@firebase/analytics": "^0.10.13",
"@firebase/app": "^0.11.4", "@firebase/app": "^0.12.1",
"@firebase/auth": "^1.10.0", "@firebase/auth": "^1.10.2",
"@firebase/firestore": "^4.7.10", "@firebase/firestore": "^4.7.12",
"@firebase/messaging": "^0.12.17", "@firebase/messaging": "^0.12.18",
"@jsreport/browser-client": "^3.1.0", "@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.6.1", "@reduxjs/toolkit": "^2.8.1",
"@sentry/cli": "^2.43.0", "@sentry/cli": "^2.45.0",
"@sentry/react": "^9.10.1", "@sentry/react": "^9.17.0",
"@sentry/vite-plugin": "^3.2.4", "@sentry/vite-plugin": "^3.4.0",
"@splitsoftware/splitio-react": "^2.1.0", "@splitsoftware/splitio-react": "^2.1.1",
"@tanem/react-nprogress": "^5.0.53", "@tanem/react-nprogress": "^5.0.53",
"antd": "^5.24.6", "antd": "^5.25.0",
"apollo-link-logger": "^2.0.1", "apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^4.2.0", "apollo-link-sentry": "^4.2.0",
"autosize": "^6.0.1", "autosize": "^6.0.1",
@@ -37,18 +37,18 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"env-cmd": "^10.1.0", "env-cmd": "^10.1.0",
"exifr": "^7.1.3", "exifr": "^7.1.3",
"graphql": "^16.10.0", "graphql": "^16.11.0",
"i18next": "^24.2.3", "i18next": "^24.2.3",
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.1.0",
"immutability-helper": "^3.1.1", "immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.6", "libphonenumber-js": "^1.12.8",
"logrocket": "^9.0.2", "logrocket": "^9.0.2",
"markerjs2": "^2.32.4", "markerjs2": "^2.32.4",
"memoize-one": "^6.0.0", "memoize-one": "^6.0.0",
"normalize-url": "^8.0.1", "normalize-url": "^8.0.1",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"query-string": "^9.1.1", "query-string": "^9.1.2",
"raf-schd": "^4.0.3", "raf-schd": "^4.0.3",
"react": "^18.3.1", "react": "^18.3.1",
"react-big-calendar": "^1.18.0", "react-big-calendar": "^1.18.0",
@@ -69,17 +69,17 @@
"react-resizable": "^3.0.5", "react-resizable": "^3.0.5",
"react-router-dom": "^6.30.0", "react-router-dom": "^6.30.0",
"react-sticky": "^6.0.3", "react-sticky": "^6.0.3",
"react-virtuoso": "^4.12.6", "react-virtuoso": "^4.12.7",
"recharts": "^2.15.0", "recharts": "^2.15.2",
"redux": "^5.0.1", "redux": "^5.0.1",
"redux-actions": "^3.0.3", "redux-actions": "^3.0.3",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"redux-saga": "^1.3.0", "redux-saga": "^1.3.0",
"redux-state-sync": "^3.1.4", "redux-state-sync": "^3.1.4",
"reselect": "^5.1.1", "reselect": "^5.1.1",
"sass": "^1.86.1", "sass": "^1.86.3",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"styled-components": "^6.1.16", "styled-components": "^6.1.18",
"subscriptions-transport-ws": "^0.11.0", "subscriptions-transport-ws": "^0.11.0",
"use-memo-one": "^1.1.3", "use-memo-one": "^1.1.3",
"vite-plugin-ejs": "^1.7.0", "vite-plugin-ejs": "^1.7.0",
@@ -129,38 +129,38 @@
"devDependencies": { "devDependencies": {
"@ant-design/icons": "^6.0.0", "@ant-design/icons": "^6.0.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.26.3", "@babel/preset-react": "^7.27.1",
"@dotenvx/dotenvx": "^1.39.0", "@dotenvx/dotenvx": "^1.43.0",
"@emotion/babel-plugin": "^11.13.5", "@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@eslint/js": "^9.23.0", "@eslint/js": "^9.26.0",
"@playwright/test": "^1.51.1", "@playwright/test": "^1.51.1",
"@sentry/webpack-plugin": "^3.2.4", "@sentry/webpack-plugin": "^3.4.0",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.3.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"browserslist": "^4.24.4", "browserslist": "^4.24.5",
"browserslist-to-esbuild": "^2.1.1", "browserslist-to-esbuild": "^2.1.1",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-config-react-app": "^7.0.1", "eslint-config-react-app": "^7.0.1",
"eslint-plugin-react": "^7.37.4", "eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0", "globals": "^15.15.0",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"memfs": "^4.17.0", "memfs": "^4.17.1",
"os-browserify": "^0.3.0", "os-browserify": "^0.3.0",
"playwright": "^1.51.1", "playwright": "^1.51.1",
"react-error-overlay": "^6.1.0", "react-error-overlay": "^6.1.0",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.3", "source-map-explorer": "^2.5.3",
"vite": "^6.2.4", "vite": "^6.3.5",
"vite-plugin-babel": "^1.3.0", "vite-plugin-babel": "^1.3.1",
"vite-plugin-eslint": "^1.8.1", "vite-plugin-eslint": "^1.8.1",
"vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pwa": "^1.0.0", "vite-plugin-pwa": "^1.0.0",
"vite-plugin-style-import": "^2.0.0", "vite-plugin-style-import": "^2.0.0",
"vitest": "^3.1.1", "vitest": "^3.1.3",
"workbox-window": "^7.3.0" "workbox-window": "^7.3.0"
} }
} }

View File

@@ -142,11 +142,10 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
> >
<ProductFruitsWrapper <ProductFruitsWrapper
currentUser={currentUser} currentUser={currentUser}
workspaceCode={InstanceRenderMgr({ bodyshop={bodyshop}
imex: null, workspaceCode={bodyshop?.tours_enabled ? "9BkbEseqNqxw8jUH" : ""}
rome: "9BkbEseqNqxw8jUH"
})}
/> />
<NotificationProvider> <NotificationProvider>
<Routes> <Routes>
<Route <Route

View File

@@ -1,8 +1,16 @@
import React from "react"; import React from "react";
import { ProductFruits } from "react-product-fruits";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { ProductFruits } from "react-product-fruits";
import dayjs from "dayjs";
const ProductFruitsWrapper = React.memo(({ currentUser, bodyshop, workspaceCode }) => {
const featureProps = bodyshop?.features
? Object.entries(bodyshop.features).reduce((acc, [key, value]) => {
acc[key] = value === true || (typeof value === "string" && dayjs(value).isAfter(dayjs()));
return acc;
}, {})
: {};
const ProductFruitsWrapper = React.memo(({ currentUser, workspaceCode }) => {
return ( return (
workspaceCode && workspaceCode &&
currentUser?.authorized === true && currentUser?.authorized === true &&
@@ -14,7 +22,8 @@ const ProductFruitsWrapper = React.memo(({ currentUser, workspaceCode }) => {
language="en" language="en"
user={{ user={{
email: currentUser.email, email: currentUser.email,
username: currentUser.email username: currentUser.email,
props: featureProps
}} }}
/> />
) )
@@ -28,5 +37,6 @@ ProductFruitsWrapper.propTypes = {
authorized: PropTypes.bool, authorized: PropTypes.bool,
email: PropTypes.string email: PropTypes.string
}), }),
workspaceCode: PropTypes.string workspaceCode: PropTypes.string,
bodyshop: PropTypes.object
}; };

View File

@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
const { Option } = Select; const { Option } = Select;
//To be used as a form element only. //To be used as a form element only.
const EmployeeSearchSelect = ({ options, ...props }) => { const EmployeeSearchSelect = ({ options, showEmail, ...props }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -21,12 +21,16 @@ const EmployeeSearchSelect = ({ options, ...props }) => {
{options {options
? options.map((o) => ( ? options.map((o) => (
<Option key={o.id} value={o.id} search={`${o.employee_number} ${o.first_name} ${o.last_name}`}> <Option key={o.id} value={o.id} search={`${o.employee_number} ${o.first_name} ${o.last_name}`}>
<Space> <Space size="small">
{`${o.employee_number} ${o.first_name} ${o.last_name}`} {`${o.employee_number ?? ""} ${o.first_name} ${o.last_name}`}
<Tag color="green" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
<Tag color="green">
{o.flat_rate ? t("timetickets.labels.flat_rate") : t("timetickets.labels.straight_time")} {o.flat_rate ? t("timetickets.labels.flat_rate") : t("timetickets.labels.straight_time")}
</Tag> </Tag>
{showEmail && o.user_email ? (
<Tag color="blue" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
{o.user_email}
</Tag>
) : null}
</Space> </Space>
</Option> </Option>
)) ))

View File

@@ -20,6 +20,7 @@ function FeatureWrapper({
children, children,
upsellComponent, upsellComponent,
bypass, bypass,
// eslint-disable-next-line no-unused-vars
...restProps ...restProps
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -78,7 +79,11 @@ export function HasFeatureAccess({ featureName, bodyshop, bypass, debug = false
} }
return true; return true;
} }
return bodyshop?.features?.allAccess || dayjs(bodyshop?.features[featureName]).isAfter(dayjs()); return (
bodyshop?.features?.allAccess ||
bodyshop?.features?.[featureName] ||
dayjs(bodyshop?.features[featureName]).isAfter(dayjs())
);
} }
export default connect(mapStateToProps, null)(FeatureWrapper); export default connect(mapStateToProps, null)(FeatureWrapper);

View File

@@ -1,5 +1,5 @@
import { AlertFilled } from "@ant-design/icons"; import { AlertFilled } from "@ant-design/icons";
import { useMutation } from "@apollo/client"; import { useLazyQuery, useMutation } from "@apollo/client";
import { Button, Divider, Dropdown, Form, Input, Popover, Select, Space } from "antd"; import { Button, Divider, Dropdown, Form, Input, Popover, Select, Space } from "antd";
import parsePhoneNumber from "libphonenumber-js"; import parsePhoneNumber from "libphonenumber-js";
import queryString from "query-string"; import queryString from "query-string";
@@ -8,24 +8,30 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries"; import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
import { GET_JOB_BY_PK_QUICK_INTAKE, JOB_PRODUCTION_TOGGLE } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions"; import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { setModalContext } from "../../redux/modals/modals.actions"; import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import CurrencyFormatter from "../../utils/CurrencyFormatter"; import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateTimeFormatterFunction } from "../../utils/DateFormatter";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import { GenerateDocument } from "../../utils/RenderTemplate"; import { GenerateDocument } from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants"; import { TemplateList } from "../../utils/TemplateConstants";
import ChatOpenButton from "../chat-open-button/chat-open-button.component"; import ChatOpenButton from "../chat-open-button/chat-open-button.component";
import DataLabel from "../data-label/data-label.component"; import DataLabel from "../data-label/data-label.component";
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component"; import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component"; import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component";
import ScheduleManualEvent from "../schedule-manual-event/schedule-manual-event.component"; import ScheduleManualEvent from "../schedule-manual-event/schedule-manual-event.component";
import { HasFeatureAccess } from "./../feature-wrapper/feature-wrapper.component";
import ScheduleAtChange from "./job-at-change.component"; import ScheduleAtChange from "./job-at-change.component";
import ScheduleEventColor from "./schedule-event.color.component"; import ScheduleEventColor from "./schedule-event.color.component";
import ScheduleEventNote from "./schedule-event.note.component"; import ScheduleEventNote from "./schedule-event.note.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
@@ -33,7 +39,8 @@ const mapStateToProps = createStructuredSelector({
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setScheduleContext: (context) => dispatch(setModalContext({ context: context, modal: "schedule" })), setScheduleContext: (context) => dispatch(setModalContext({ context: context, modal: "schedule" })),
openChatByPhone: (phone) => dispatch(openChatByPhone(phone)), openChatByPhone: (phone) => dispatch(openChatByPhone(phone)),
setMessage: (text) => dispatch(setMessage(text)) setMessage: (text) => dispatch(setMessage(text)),
insertAuditTrail: ({ jobid, operation }) => dispatch(insertAuditTrail({ jobid, operation }))
}); });
export function ScheduleEventComponent({ export function ScheduleEventComponent({
@@ -43,16 +50,41 @@ export function ScheduleEventComponent({
event, event,
refetch, refetch,
handleCancel, handleCancel,
setScheduleContext setScheduleContext,
insertAuditTrail
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const history = useNavigate(); const history = useNavigate();
const searchParams = queryString.parse(useLocation().search); const searchParams = queryString.parse(useLocation().search);
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT); const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
const [mutationUpdateJob] = useMutation(JOB_PRODUCTION_TOGGLE);
const [title, setTitle] = useState(event.title); const [title, setTitle] = useState(event.title);
const { socket } = useSocket(); const { socket } = useSocket();
const notification = useNotification(); const notification = useNotification();
const [form] = Form.useForm();
const [popOverVisible, setPopOverVisible] = useState(false);
const [getJobDetails] = useLazyQuery(GET_JOB_BY_PK_QUICK_INTAKE, {
variables: { id: event.job.id },
onCompleted: (data) => {
if (data?.jobs_by_pk) {
const totalHours =
(data.jobs_by_pk.labhrs?.aggregate?.sum?.mod_lb_hrs || 0) +
(data.jobs_by_pk.larhrs?.aggregate?.sum?.mod_lb_hrs || 0);
form.setFieldsValue({
actual_in: data.jobs_by_pk.actual_in ? data.jobs_by_pk.actual_in : dayjs(),
scheduled_completion: data.jobs_by_pk.scheduled_completion
? data.jobs_by_pk.scheduled_completion
: totalHours && bodyshop.ss_configuration.nobusinessdays
? dayjs().businessDaysAdd(totalHours / (bodyshop.target_touchtime || 1), "day")
: dayjs().add(totalHours / (bodyshop.target_touchtime || 1), "day"),
scheduled_delivery: data.jobs_by_pk.scheduled_delivery
});
}
},
fetchPolicy: "network-only"
});
const blockContent = ( const blockContent = (
<Space direction="vertical" wrap> <Space direction="vertical" wrap>
@@ -89,6 +121,74 @@ export function ScheduleEventComponent({
</Space> </Space>
); );
const handleConvert = async (values) => {
const res = await mutationUpdateJob({
variables: {
jobId: event.job.id,
job: {
...values,
status: bodyshop.md_ro_statuses.default_arrived,
inproduction: true
}
}
});
if (!res.errors) {
notification["success"]({
message: t("jobs.successes.converted")
});
insertAuditTrail({
jobid: event.job.id,
operation: AuditTrailMapping.jobintake(
res.data.update_jobs.returning[0].status,
DateTimeFormatterFunction(values.scheduled_completion)
)
});
setPopOverVisible(false);
refetch();
}
};
const popMenu = (
<div onClick={(e) => e.stopPropagation()}>
<Form layout="vertical" form={form} onFinish={handleConvert}>
<Form.Item
name={["actual_in"]}
label={t("jobs.fields.actual_in")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<FormDateTimePickerComponent disabled={event.ro_number} />
</Form.Item>
<Form.Item
name={["scheduled_completion"]}
label={t("jobs.fields.scheduled_completion")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<FormDateTimePickerComponent disabled={event.ro_number} />
</Form.Item>
<Form.Item name={["scheduled_delivery"]} label={t("jobs.fields.scheduled_delivery")}>
<FormDateTimePickerComponent disabled={event.ro_number} />
</Form.Item>
<Space wrap>
<Button type="primary" onClick={() => form.submit()}>
{t("general.actions.save")}
</Button>
</Space>
</Form>
</div>
);
const popoverContent = ( const popoverContent = (
<div style={{ maxWidth: "40vw" }}> <div style={{ maxWidth: "40vw" }}>
{!event.isintake ? ( {!event.isintake ? (
@@ -294,7 +394,7 @@ export function ScheduleEventComponent({
) : ( ) : (
<ScheduleManualEvent event={event} /> <ScheduleManualEvent event={event} />
)} )}
{event.isintake ? ( {event.isintake && HasFeatureAccess({ featureName: "checklist", bodyshop }) ? (
<Link <Link
to={{ to={{
pathname: `/manage/jobs/${event.job && event.job.id}/intake`, pathname: `/manage/jobs/${event.job && event.job.id}/intake`,
@@ -303,7 +403,21 @@ export function ScheduleEventComponent({
> >
<Button disabled={event.arrived}>{t("appointments.actions.intake")}</Button> <Button disabled={event.arrived}>{t("appointments.actions.intake")}</Button>
</Link> </Link>
) : null} ) : (
<Popover //open={open}
content={popMenu}
open={popOverVisible}
onOpenChange={setPopOverVisible}
onClick={(e) => {
getJobDetails();
e.stopPropagation();
}}
getPopupContainer={(trigger) => trigger.parentNode}
trigger="click"
>
<Button disabled={event.arrived}>{t("jobs.actions.intake_quick")}</Button>
</Popover>
)}
</Space> </Space>
</div> </div>
); );

View File

@@ -104,6 +104,7 @@ export default function JobWatcherToggleComponent({
} }
placeholder={t("notifications.labels.employee-search")} placeholder={t("notifications.labels.employee-search")}
value={selectedWatcher} value={selectedWatcher}
showEmail={true}
onChange={(value) => { onChange={(value) => {
setSelectedWatcher(value); setSelectedWatcher(value);
handleWatcherSelect(value); handleWatcherSelect(value);

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import { Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd"; import { Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -188,6 +187,12 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
<Form.Item label={t("jobs.fields.tlos_ind")} name="tlos_ind" valuePropName="checked"> <Form.Item label={t("jobs.fields.tlos_ind")} name="tlos_ind" valuePropName="checked">
<Switch disabled={jobRO} /> <Switch disabled={jobRO} />
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.hit_and_run")} name="hit_and_run" valuePropName="checked">
<Switch disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.acv_amount")} name="acv_amount">
<CurrencyInput disabled={jobRO} min={0} />
</Form.Item>
</FormRow> </FormRow>
</Col> </Col>
<Col {...lossColDamage}> <Col {...lossColDamage}>

View File

@@ -133,6 +133,16 @@ export function JobsDetailHeaderActions({
const { socket } = useSocket(); const { socket } = useSocket();
const notification = useNotification(); const notification = useNotification();
const isDevEnv = import.meta.env.DEV;
const isProdEnv = import.meta.env.PROD;
const userEmail = currentUser?.email || "";
const devEmails = ["imex.dev", "rome.dev"];
const prodEmails = ["imex.prod", "rome.prod", "imex.test", "rome.test"];
const hasValidEmail = (emails) => emails.some((email) => userEmail.endsWith(email));
const canSubmitForTesting = (isDevEnv && hasValidEmail(devEmails)) || (isProdEnv && hasValidEmail(prodEmails));
const { const {
treatments: { ImEXPay } treatments: { ImEXPay }
} = useSplitTreatments({ } = useSplitTreatments({
@@ -171,7 +181,7 @@ export function JobsDetailHeaderActions({
{ defaultOpenStatus: bodyshop.md_ro_statuses.default_imported }, { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported },
(newJobId) => { (newJobId) => {
history(`/manage/jobs/${newJobId}`); history(`/manage/jobs/${newJobId}`);
notification["success"]({ notification.success({
message: t("jobs.successes.duplicated") message: t("jobs.successes.duplicated")
}); });
}, },
@@ -181,7 +191,7 @@ export function JobsDetailHeaderActions({
const handleDuplicateConfirm = () => const handleDuplicateConfirm = () =>
DuplicateJob(client, job.id, { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported }, (newJobId) => { DuplicateJob(client, job.id, { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported }, (newJobId) => {
history(`/manage/jobs/${newJobId}`); history(`/manage/jobs/${newJobId}`);
notification["success"]({ notification.success({
message: t("jobs.successes.duplicated") message: t("jobs.successes.duplicated")
}); });
}); });
@@ -217,13 +227,13 @@ export function JobsDetailHeaderActions({
const result = await deleteJob({ variables: { id: job.id } }); const result = await deleteJob({ variables: { id: job.id } });
if (!result.errors) { if (!result.errors) {
notification["success"]({ notification.success({
message: t("jobs.successes.delete") message: t("jobs.successes.delete")
}); });
//go back to jobs list. //go back to jobs list.
history(`/manage/`); history(`/manage/`);
} else { } else {
notification["error"]({ notification.error({
message: t("jobs.errors.deleted", { message: t("jobs.errors.deleted", {
error: JSON.stringify(result.errors) error: JSON.stringify(result.errors)
}) })
@@ -275,9 +285,9 @@ export function JobsDetailHeaderActions({
}); });
if (!result.errors) { if (!result.errors) {
notification["success"]({ message: t("csi.successes.created") }); notification.success({ message: t("csi.successes.created") });
} else { } else {
notification["error"]({ notification.error({
message: t("csi.errors.creating", { message: t("csi.errors.creating", {
message: JSON.stringify(result.errors) message: JSON.stringify(result.errors)
}) })
@@ -316,7 +326,7 @@ export function JobsDetailHeaderActions({
`${window.location.protocol}//${window.location.host}/csi/${result.data.insert_csi.returning[0].id}` `${window.location.protocol}//${window.location.host}/csi/${result.data.insert_csi.returning[0].id}`
); );
} else { } else {
notification["error"]({ notification.error({
message: t("messaging.error.invalidphone") message: t("messaging.error.invalidphone")
}); });
} }
@@ -328,7 +338,7 @@ export function JobsDetailHeaderActions({
); );
} }
} else { } else {
notification["error"]({ notification.error({
message: t("csi.errors.notconfigured") message: t("csi.errors.notconfigured")
}); });
} }
@@ -358,7 +368,7 @@ export function JobsDetailHeaderActions({
}); });
setMessage(`${window.location.protocol}//${window.location.host}/csi/${job.csiinvites[0].id}`); setMessage(`${window.location.protocol}//${window.location.host}/csi/${job.csiinvites[0].id}`);
} else { } else {
notification["error"]({ notification.error({
message: t("messaging.error.invalidphone") message: t("messaging.error.invalidphone")
}); });
} }
@@ -398,7 +408,7 @@ export function JobsDetailHeaderActions({
}); });
if (!result.errors) { if (!result.errors) {
notification["success"]({ notification.success({
message: t("jobs.successes.voided") message: t("jobs.successes.voided")
}); });
insertAuditTrail({ insertAuditTrail({
@@ -409,7 +419,7 @@ export function JobsDetailHeaderActions({
//go back to jobs list. //go back to jobs list.
history(`/manage/`); history(`/manage/`);
} else { } else {
notification["error"]({ notification.error({
message: t("jobs.errors.voiding", { message: t("jobs.errors.voiding", {
error: JSON.stringify(result.errors) error: JSON.stringify(result.errors)
}) })
@@ -442,7 +452,7 @@ export function JobsDetailHeaderActions({
console.log("handle -> XML", QbXmlResponse); console.log("handle -> XML", QbXmlResponse);
} catch (error) { } catch (error) {
console.log("Error getting QBXML from Server.", error); console.log("Error getting QBXML from Server.", error);
notification["error"]({ notification.error({
message: t("jobs.errors.exporting", { message: t("jobs.errors.exporting", {
error: "Unable to retrieve QBXML. " + JSON.stringify(error.message) error: "Unable to retrieve QBXML. " + JSON.stringify(error.message)
}) })
@@ -460,7 +470,7 @@ export function JobsDetailHeaderActions({
}); });
} catch (error) { } catch (error) {
console.log("Error connecting to quickbooks or partner.", error); console.log("Error connecting to quickbooks or partner.", error);
notification["error"]({ notification.error({
message: t("jobs.errors.exporting-partner") message: t("jobs.errors.exporting-partner")
}); });
@@ -556,7 +566,7 @@ export function JobsDetailHeaderActions({
} }
}); });
if (!jobUpdate.errors) { if (!jobUpdate.errors) {
notification["success"]({ notification.success({
message: t("appointments.successes.canceled") message: t("appointments.successes.canceled")
}); });
insertAuditTrail({ insertAuditTrail({
@@ -931,11 +941,11 @@ export function JobsDetailHeaderActions({
}); });
if (!result.errors) { if (!result.errors) {
notification["success"]({ notification.success({
message: t("jobs.successes.partsqueue") message: t("jobs.successes.partsqueue")
}); });
} else { } else {
notification["error"]({ notification.error({
message: t("jobs.errors.saving", { message: t("jobs.errors.saving", {
error: JSON.stringify(result.errors) error: JSON.stringify(result.errors)
}) })
@@ -1111,6 +1121,27 @@ export function JobsDetailHeaderActions({
}); });
} }
if (canSubmitForTesting) {
menuItems.push({
key: "submitfortesting",
id: "job-actions-submitfortesting",
label: t("menus.jobsactions.submit-for-testing"),
onClick: async () => {
try {
await axios.post("/job/totals-recorder", { id: job.id });
notification.success({
message: t("general.messages.submit-for-testing")
});
} catch (err) {
console.error(`Error submitting job for testing: ${err?.message}`);
notification.error({
message: t("general.errors.submit-for-testing-error")
});
}
}
});
}
const menu = { const menu = {
items: menuItems, items: menuItems,
key: "popovermenu" key: "popovermenu"

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { GET_JOB_BY_PK_QUICK_INTAKE, JOB_PRODUCTION_TOGGLE } from "../../graphql/jobs.queries"; import { GET_JOB_BY_PK_QUICK_INTAKE, JOB_PRODUCTION_TOGGLE } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions"; import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors"; import { selectJobReadOnly } from "../../redux/application/application.selectors";
@@ -12,7 +13,6 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings"; import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { DateTimeFormatterFunction } from "../../utils/DateFormatter"; import { DateTimeFormatterFunction } from "../../utils/DateFormatter";
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component"; import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx"; import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
@@ -44,9 +44,16 @@ export function JobsDetailHeaderActionsToggleProduction({
variables: { id: job.id }, variables: { id: job.id },
onCompleted: (data) => { onCompleted: (data) => {
if (data?.jobs_by_pk) { if (data?.jobs_by_pk) {
const totalHours =
(data.jobs_by_pk.labhrs?.aggregate?.sum?.mod_lb_hrs || 0) +
(data.jobs_by_pk.larhrs?.aggregate?.sum?.mod_lb_hrs || 0);
form.setFieldsValue({ form.setFieldsValue({
actual_in: data.jobs_by_pk.actual_in ? data.jobs_by_pk.actual_in : dayjs(), actual_in: data.jobs_by_pk.actual_in ? data.jobs_by_pk.actual_in : dayjs(),
scheduled_completion: data.jobs_by_pk.scheduled_completion, scheduled_completion: data.jobs_by_pk.scheduled_completion
? data.jobs_by_pk.scheduled_completion
: totalHours && bodyshop.ss_configuration.nobusinessdays
? dayjs().businessDaysAdd(totalHours / (bodyshop.target_touchtime || 1), "day")
: dayjs().add(totalHours / (bodyshop.target_touchtime || 1), "day"),
actual_completion: data.jobs_by_pk.actual_completion, actual_completion: data.jobs_by_pk.actual_completion,
scheduled_delivery: data.jobs_by_pk.scheduled_delivery, scheduled_delivery: data.jobs_by_pk.scheduled_delivery,
actual_delivery: data.jobs_by_pk.actual_delivery actual_delivery: data.jobs_by_pk.actual_delivery

View File

@@ -1,15 +1,18 @@
import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined, WarningFilled } from "@ant-design/icons"; import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined, WarningFilled } from "@ant-design/icons";
import { Card, Col, Divider, Row, Space, Tag, Tooltip } from "antd"; import { Card, Checkbox, Col, Divider, Row, Space, Tag, Tooltip } from "antd";
import React, { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useMutation } from "@apollo/client";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectJobReadOnly } from "../../redux/application/application.selectors"; import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions"; import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import { UPDATE_JOB } from "../../graphql/jobs.queries";
import CurrencyFormatter from "../../utils/CurrencyFormatter"; import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateTimeFormatter } from "../../utils/DateFormatter"; import { DateTimeFormatter } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import PhoneNumberFormatter from "../../utils/PhoneFormatter"; import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import ChatOpenButton from "../chat-open-button/chat-open-button.component"; import ChatOpenButton from "../chat-open-button/chat-open-button.component";
import DataLabel from "../data-label/data-label.component"; import DataLabel from "../data-label/data-label.component";
@@ -21,7 +24,7 @@ import ProductionListColumnComment from "../production-list-columns/production-l
import ProductionListColumnProductionNote from "../production-list-columns/production-list-columns.productionnote.component"; import ProductionListColumnProductionNote from "../production-list-columns/production-list-columns.productionnote.component";
import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component"; import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component";
import "./jobs-detail-header.styles.scss"; import "./jobs-detail-header.styles.scss";
import dayjs from "../../utils/day"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly, jobRO: selectJobReadOnly,
@@ -29,41 +32,55 @@ const mapStateToProps = createStructuredSelector({
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setPrintCenterContext: (context) => dispatch(setModalContext({ context: context, modal: "printCenter" })) setPrintCenterContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "printCenter"
})
)
}); });
const colSpan = { const colSpan = {
xs: { xs: { span: 24 },
span: 24 sm: { span: 24 },
}, md: { span: 12 },
sm: { lg: { span: 6 },
span: 24 xl: { span: 6 }
},
md: {
span: 12
},
lg: {
span: 6
},
xl: {
span: 6
}
}; };
export function JobsDetailHeader({ job, bodyshop, disabled }) { export function JobsDetailHeader({ job, bodyshop, disabled }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { notification } = useNotification();
const [notesClamped, setNotesClamped] = useState(true); const [notesClamped, setNotesClamped] = useState(true);
const vehicleTitle = `${job.v_model_yr || ""} ${job.v_color || ""} const [updateJob] = useMutation(UPDATE_JOB);
${job.v_make_desc || ""} const vehicleTitle =
${job.v_model_desc || ""}`.trim(); `${job.v_model_yr || ""} ${job.v_color || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim();
const bodyHrs = job.joblines.filter((j) => j.mod_lbr_ty !== "LAR").reduce((acc, val) => acc + val.mod_lb_hrs, 0); const bodyHrs = job.joblines.filter((j) => j.mod_lbr_ty !== "LAR").reduce((acc, val) => acc + val.mod_lb_hrs, 0);
const refinishHrs = job.joblines const refinishHrs = job.joblines
.filter((line) => line.mod_lbr_ty === "LAR") .filter((line) => line.mod_lbr_ty === "LAR")
.reduce((acc, val) => acc + val.mod_lb_hrs, 0); .reduce((acc, val) => acc + val.mod_lb_hrs, 0);
const ownerTitle = OwnerNameDisplayFunction(job).trim(); const ownerTitle = OwnerNameDisplayFunction(job).trim();
// Handle checkbox changes
const handleCheckboxChange = async (field, checked) => {
const value = checked ? dayjs().toISOString() : null;
try {
await updateJob({
variables: {
jobId: job.id,
job: { [field]: value }
},
refetchQueries: ["GET_JOB_BY_PK"],
awaitRefetchQueries: true
});
} catch (error) {
notification.error({
message: t("jobs.errors.saving", { error: error.message })
});
}
};
return ( return (
<Row gutter={[16, 16]} style={{ alignItems: "stretch" }}> <Row gutter={[16, 16]} style={{ alignItems: "stretch" }}>
<Col {...colSpan}> <Col {...colSpan}>
@@ -72,11 +89,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
<DataLabel label={t("jobs.fields.status")}> <DataLabel label={t("jobs.fields.status")}>
<Space wrap> <Space wrap>
{job.status} {job.status}
{job.inproduction && ( {job.inproduction && <Tag color="#f50">{t("jobs.labels.inproduction")}</Tag>}
<Tag color="#f50" key="production">
{t("jobs.labels.inproduction")}
</Tag>
)}
{job.suspended && <PauseCircleOutlined style={{ color: "orangered" }} />} {job.suspended && <PauseCircleOutlined style={{ color: "orangered" }} />}
{job.iouparent && ( {job.iouparent && (
<Link to={`/manage/jobs/${job.iouparent}`}> <Link to={`/manage/jobs/${job.iouparent}`}>
@@ -110,7 +123,6 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
<span style={{ margin: "0rem .5rem" }}>/</span> <span style={{ margin: "0rem .5rem" }}>/</span>
<CurrencyFormatter>{job.owner_owing}</CurrencyFormatter> <CurrencyFormatter>{job.owner_owing}</CurrencyFormatter>
</DataLabel> </DataLabel>
<DataLabel label={t("jobs.fields.alt_transport")}> <DataLabel label={t("jobs.fields.alt_transport")}>
{job.alt_transport} {job.alt_transport}
<JobAltTransportChange job={job} /> <JobAltTransportChange job={job} />
@@ -127,11 +139,39 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
))} ))}
</DataLabel> </DataLabel>
)} )}
<DataLabel label={t("jobs.fields.production_vars.note")}> <DataLabel label={t("jobs.fields.production_vars.note")}>
<ProductionListColumnProductionNote record={job} /> <ProductionListColumnProductionNote record={job} />
</DataLabel> </DataLabel>
<DataLabel label={t("jobs.fields.estimate_sent_approval")}>
<Space>
<Checkbox
checked={!!job.estimate_sent_approval}
onChange={(e) => handleCheckboxChange("estimate_sent_approval", e.target.checked)}
disabled={disabled}
>
{job.estimate_sent_approval && (
<span style={{ color: "#888" }}>
<DateTimeFormatter>{job.estimate_sent_approval}</DateTimeFormatter>
</span>
)}
</Checkbox>
</Space>
</DataLabel>
<DataLabel label={t("jobs.fields.estimate_approved")}>
<Space>
<Checkbox
checked={!!job.estimate_approved}
onChange={(e) => handleCheckboxChange("estimate_approved", e.target.checked)}
disabled={disabled}
>
{job.estimate_approved && (
<span style={{ color: "#888" }}>
<DateTimeFormatter>{job.estimate_approved}</DateTimeFormatter>
</span>
)}
</Checkbox>
</Space>
</DataLabel>
<Space wrap> <Space wrap>
{job.special_coverage_policy && ( {job.special_coverage_policy && (
<Tag color="tomato"> <Tag color="tomato">
@@ -149,6 +189,14 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
</Space> </Space>
</Tag> </Tag>
)} )}
{job.hit_and_run && (
<Tag color="green">
<Space>
<WarningFilled />
<span>{t("jobs.fields.hit_and_run")}</span>
</Space>
</Tag>
)}
</Space> </Space>
</div> </div>
</Card> </Card>

View File

@@ -1,12 +1,10 @@
import { Button, Space } from "antd"; import { Button, Space } from "antd";
import axios from "axios"; import axios from "axios";
import React, { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
import cleanAxios from "../../utils/CleanAxios"; import cleanAxios from "../../utils/CleanAxios";
import formatBytes from "../../utils/formatbytes"; import formatBytes from "../../utils/formatbytes";
//import yauzl from "yauzl";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
@@ -28,7 +26,7 @@ const mapDispatchToProps = (dispatch) => ({
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyDownloadButton); export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyDownloadButton);
export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, identifier }) { export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, identifier, jobId }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [download, setDownload] = useState(null); const [download, setDownload] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -46,6 +44,7 @@ export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, i
}; };
}); });
} }
function standardMediaDownload(bufferData) { function standardMediaDownload(bufferData) {
const a = document.createElement("a"); const a = document.createElement("a");
const url = window.URL.createObjectURL(new Blob([bufferData])); const url = window.URL.createObjectURL(new Blob([bufferData]));
@@ -53,13 +52,14 @@ export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, i
a.download = `${identifier || "documents"}.zip`; a.download = `${identifier || "documents"}.zip`;
a.click(); a.click();
} }
const handleDownload = async () => { const handleDownload = async () => {
logImEXEvent("jobs_documents_download"); logImEXEvent("jobs_documents_download");
setLoading(true); setLoading(true);
const zipUrl = await axios({ const zipUrl = await axios({
url: "/media/imgproxy/download", url: "/media/imgproxy/download",
method: "POST", method: "POST",
data: { documentids: imagesToDownload.map((_) => _.id) } data: { jobId, documentids: imagesToDownload.map((_) => _.id) }
}); });
const theDownloadedZip = await cleanAxios({ const theDownloadedZip = await cleanAxios({

View File

@@ -75,7 +75,7 @@ function JobsDocumentsImgproxyComponent({
<SyncOutlined /> <SyncOutlined />
</Button> </Button>
<JobsDocumentsGallerySelectAllComponent galleryImages={galleryImages} setGalleryImages={setGalleryImages} /> <JobsDocumentsGallerySelectAllComponent galleryImages={galleryImages} setGalleryImages={setGalleryImages} />
<JobsDocumentsDownloadButton galleryImages={galleryImages} identifier={downloadIdentifier} /> <JobsDocumentsDownloadButton galleryImages={galleryImages} identifier={downloadIdentifier} jobId={jobId} />
<JobsDocumentsDeleteButton <JobsDocumentsDeleteButton
galleryImages={galleryImages} galleryImages={galleryImages}
deletionCallback={billsCallback || fetchThumbnails || refetch} deletionCallback={billsCallback || fetchThumbnails || refetch}

View File

@@ -1,12 +1,16 @@
import { useMutation, useQuery } from "@apollo/client"; import { useMutation, useQuery } from "@apollo/client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, Card, Checkbox, Form, Space, Table } from "antd"; import { Button, Card, Checkbox, Divider, Form, Space, Switch, Table, Typography } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectCurrentUser } from "../../redux/user/user.selectors"; import { selectCurrentUser } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import { QUERY_NOTIFICATION_SETTINGS, UPDATE_NOTIFICATION_SETTINGS } from "../../graphql/user.queries.js"; import {
QUERY_NOTIFICATION_SETTINGS,
UPDATE_NOTIFICATION_SETTINGS,
UPDATE_NOTIFICATIONS_AUTOADD
} from "../../graphql/user.queries.js";
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js"; import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx"; import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
@@ -24,9 +28,11 @@ const NotificationSettingsForm = ({ currentUser }) => {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [initialValues, setInitialValues] = useState({}); const [initialValues, setInitialValues] = useState({});
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [autoAddEnabled, setAutoAddEnabled] = useState(false);
const [initialAutoAdd, setInitialAutoAdd] = useState(false);
const notification = useNotification(); const notification = useNotification();
// Fetch notification settings. // Fetch notification settings and notifications_autoadd
const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, { const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, {
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only", nextFetchPolicy: "network-only",
@@ -34,13 +40,16 @@ const NotificationSettingsForm = ({ currentUser }) => {
skip: !currentUser skip: !currentUser
}); });
const [updateNotificationSettings, { loading: saving }] = useMutation(UPDATE_NOTIFICATION_SETTINGS); const [updateNotificationSettings, { loading: savingSettings }] = useMutation(UPDATE_NOTIFICATION_SETTINGS);
const [updateNotificationsAutoAdd, { loading: savingAutoAdd }] = useMutation(UPDATE_NOTIFICATIONS_AUTOADD);
// Populate form with fetched data. // Populate form with fetched data
useEffect(() => { useEffect(() => {
if (data?.associations?.length > 0) { if (data?.associations?.length > 0) {
const settings = data.associations[0].notification_settings || {}; const settings = data.associations[0].notification_settings || {};
// Ensure each scenario has an object with { app, email, fcm }. const autoAdd = data.associations[0].notifications_autoadd ?? false;
// Ensure each scenario has an object with { app, email, fcm }
const formattedValues = notificationScenarios.reduce((acc, scenario) => { const formattedValues = notificationScenarios.reduce((acc, scenario) => {
acc[scenario] = settings[scenario] ?? { app: false, email: false, fcm: false }; acc[scenario] = settings[scenario] ?? { app: false, email: false, fcm: false };
return acc; return acc;
@@ -48,32 +57,66 @@ const NotificationSettingsForm = ({ currentUser }) => {
setInitialValues(formattedValues); setInitialValues(formattedValues);
form.setFieldsValue(formattedValues); form.setFieldsValue(formattedValues);
setIsDirty(false); // Reset dirty state when new data loads. setAutoAddEnabled(autoAdd);
setInitialAutoAdd(autoAdd);
setIsDirty(false); // Reset dirty state when new data loads
} }
}, [data, form]); }, [data, form]);
// Handle toggle of notifications_autoadd
const handleAutoAddToggle = async (checked) => {
if (data?.associations?.length > 0) {
const userId = data.associations[0].id;
try {
const result = await updateNotificationsAutoAdd({
variables: { id: userId, autoadd: checked }
});
if (!result?.errors) {
setAutoAddEnabled(checked);
setInitialAutoAdd(checked);
notification.success({ message: t("notifications.labels.auto-add-success") });
setIsDirty(false); // Reset dirty state if only auto-add was changed
} else {
throw new Error("Failed to update auto-add setting");
}
} catch (err) {
setAutoAddEnabled(!checked); // Revert on error
notification.error({ message: t("notifications.labels.auto-add-failure") });
}
}
};
// Handle save of notification settings
const handleSave = async (values) => { const handleSave = async (values) => {
if (data?.associations?.length > 0) { if (data?.associations?.length > 0) {
const userId = data.associations[0].id; const userId = data.associations[0].id;
// Save the updated notification settings. try {
const result = await updateNotificationSettings({ variables: { id: userId, ns: values } }); const result = await updateNotificationSettings({ variables: { id: userId, ns: values } });
if (!result?.errors) { if (!result?.errors) {
notification.success({ message: t("notifications.labels.notification-settings-success") }); notification.success({ message: t("notifications.labels.notification-settings-success") });
setInitialValues(values); setInitialValues(values);
setIsDirty(false); setIsDirty(false);
} else { } else {
throw new Error("Failed to update notification settings");
}
} catch (err) {
notification.error({ message: t("notifications.labels.notification-settings-failure") }); notification.error({ message: t("notifications.labels.notification-settings-failure") });
} }
} }
}; };
// Mark the form as dirty on any manual change. // Mark the form as dirty on any manual change
const handleFormChange = () => { const handleFormChange = () => {
setIsDirty(true); setIsDirty(true);
}; };
// Check if auto-add has changed
const isAutoAddDirty = autoAddEnabled !== initialAutoAdd;
// Handle reset of form and auto-add
const handleReset = () => { const handleReset = () => {
form.setFieldsValue(initialValues); form.setFieldsValue(initialValues);
setAutoAddEnabled(initialAutoAdd);
setIsDirty(false); setIsDirty(false);
}; };
@@ -139,17 +182,25 @@ const NotificationSettingsForm = ({ currentUser }) => {
title={t("notifications.labels.notificationscenarios")} title={t("notifications.labels.notificationscenarios")}
extra={ extra={
<Space> <Space>
<Button type="default" onClick={handleReset} disabled={!isDirty}> <Typography.Text type="secondary">{t("notifications.labels.auto-add")}</Typography.Text>
<Switch
checked={autoAddEnabled}
onChange={handleAutoAddToggle}
loading={savingAutoAdd}
// checkedChildren={t("notifications.labels.auto-add-on")}
// unCheckedChildren={t("notifications.labels.auto-add-off")}
/>
<Button type="default" onClick={handleReset} disabled={!isDirty && !isAutoAddDirty}>
{t("general.actions.clear")} {t("general.actions.clear")}
</Button> </Button>
<Button type="primary" htmlType="submit" disabled={!isDirty} loading={savingSettings}>
<Button type="primary" htmlType="submit" disabled={!isDirty} loading={saving}>
{t("notifications.labels.save")} {t("notifications.labels.save")}
</Button> </Button>
</Space> </Space>
} }
> >
<Table dataSource={dataSource} columns={columns} pagination={false} bordered rowKey="key" /> <Table dataSource={dataSource} columns={columns} pagination={false} bordered rowKey="key" />
<Divider />
</Card> </Card>
</Form> </Form>
); );

View File

@@ -1,7 +1,6 @@
import React from "react";
import { Card, Form, Select } from "antd"; import { Card, Form, Select } from "antd";
import { useTranslation } from "react-i18next";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
const FilterSettings = ({ const FilterSettings = ({
selectedMdInsCos, selectedMdInsCos,

View File

@@ -1,10 +1,9 @@
import { Card, Checkbox, Col, Form, Row } from "antd"; import { Card, Checkbox, Col, Form, Row } from "antd";
import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
const InformationSettings = ({ t }) => ( const InformationSettings = ({ t }) => (
<Card title={t("production.settings.information")}> <Card title={t("production.settings.information")} style={{ maxWidth: "100%", overflowX: "auto" }}>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]} wrap>
{[ {[
"model_info", "model_info",
"ownr_nm", "ownr_nm",
@@ -21,7 +20,7 @@ const InformationSettings = ({ t }) => (
"subtotal", "subtotal",
"tasks" "tasks"
].map((item) => ( ].map((item) => (
<Col span={4} key={item}> <Col xs={24} sm={12} md={8} lg={6} key={item}>
<Form.Item name={item} valuePropName="checked"> <Form.Item name={item} valuePropName="checked">
<Checkbox>{t(`production.labels.${item}`)}</Checkbox> <Checkbox>{t(`production.labels.${item}`)}</Checkbox>
</Form.Item> </Form.Item>

View File

@@ -1,9 +1,8 @@
import { Card, Col, Form, Radio, Row } from "antd"; import { Card, Col, Form, Radio, Row } from "antd";
import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
const LayoutSettings = ({ t }) => ( const LayoutSettings = ({ t }) => (
<Card title={t("production.settings.layout")}> <Card title={t("production.settings.layout")} style={{ maxWidth: "100%", overflowX: "auto" }}>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{[ {[
{ {
@@ -48,9 +47,9 @@ const LayoutSettings = ({ t }) => (
] ]
} }
].map(({ name, label, options }) => ( ].map(({ name, label, options }) => (
<Col span={4} key={name}> <Col xs={24} sm={16} md={10} lg={8} key={name}>
<Form.Item name={name} label={label}> <Form.Item name={name} label={label}>
<Radio.Group> <Radio.Group style={{ display: "flex", flexWrap: "nowrap" }}>
{options.map((option) => ( {options.map((option) => (
<Radio.Button key={option.value.toString()} value={option.value}> <Radio.Button key={option.value.toString()} value={option.value}>
{option.label} {option.label}

View File

@@ -1,8 +1,7 @@
import { Card, Checkbox, Form } from "antd";
import PropTypes from "prop-types";
import { DragDropContext, Draggable, Droppable } from "../trello-board/dnd/lib/index.js"; import { DragDropContext, Draggable, Droppable } from "../trello-board/dnd/lib/index.js";
import { statisticsItems } from "./defaultKanbanSettings.js"; import { statisticsItems } from "./defaultKanbanSettings.js";
import { Card, Checkbox, Form } from "antd";
import React from "react";
import PropTypes from "prop-types";
const StatisticsSettings = ({ t, statisticsOrder, setStatisticsOrder, setHasChanges }) => { const StatisticsSettings = ({ t, statisticsOrder, setStatisticsOrder, setHasChanges }) => {
const onDragEnd = (result) => { const onDragEnd = (result) => {

View File

@@ -1,17 +1,17 @@
import { SettingOutlined } from "@ant-design/icons";
import { useMutation } from "@apollo/client"; import { useMutation } from "@apollo/client";
import { Button, Card, Col, Form, Popover, Row, Tabs } from "antd"; import { Button, Card, Col, Form, Popover, Row, Tabs } from "antd";
import { isFunction } from "lodash";
import PropTypes from "prop-types";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNotification } from "../../../contexts/Notifications/notificationContext.jsx";
import { UPDATE_KANBAN_SETTINGS } from "../../../graphql/user.queries.js"; import { UPDATE_KANBAN_SETTINGS } from "../../../graphql/user.queries.js";
import { defaultKanbanSettings, mergeWithDefaults } from "./defaultKanbanSettings.js"; import { defaultKanbanSettings, mergeWithDefaults } from "./defaultKanbanSettings.js";
import LayoutSettings from "./LayoutSettings.jsx";
import InformationSettings from "./InformationSettings.jsx";
import StatisticsSettings from "./StatisticsSettings.jsx";
import FilterSettings from "./FilterSettings.jsx"; import FilterSettings from "./FilterSettings.jsx";
import PropTypes from "prop-types"; import InformationSettings from "./InformationSettings.jsx";
import { isFunction } from "lodash"; import LayoutSettings from "./LayoutSettings.jsx";
import { useNotification } from "../../../contexts/Notifications/notificationContext.jsx"; import StatisticsSettings from "./StatisticsSettings.jsx";
import { SettingOutlined } from "@ant-design/icons";
function ProductionBoardKanbanSettings({ associationSettings, parentLoading, bodyshop, data, onSettingsChange }) { function ProductionBoardKanbanSettings({ associationSettings, parentLoading, bodyshop, data, onSettingsChange }) {
const [form] = Form.useForm(); const [form] = Form.useForm();
@@ -87,7 +87,7 @@ function ProductionBoardKanbanSettings({ associationSettings, parentLoading, bod
}; };
const overlay = ( const overlay = (
<Card style={{ minWidth: "80vw" }}> <Card style={{ maxWidth: "80vw", width: "100%"}}>
<Form form={form} onFinish={handleFinish} layout="vertical" onValuesChange={handleValuesChange}> <Form form={form} onFinish={handleFinish} layout="vertical" onValuesChange={handleValuesChange}>
<Tabs <Tabs
defaultActiveKey="1" defaultActiveKey="1"

View File

@@ -100,26 +100,28 @@ const BoardContainer = ({
const onLaneDrag = useCallback( const onLaneDrag = useCallback(
async ({ draggableId, type, source, reason, mode, destination, combine }) => { async ({ draggableId, type, source, reason, mode, destination, combine }) => {
setIsDragging(false); setIsDragging(false);
setDragTime(source.droppableId);
if (!type || type !== "lane" || !source || !destination || isEqual(source, destination)) return;
setIsProcessing(true); // 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);
dispatch( dispatch(
actions.moveCardAcrossLanes({ actions.moveCardAcrossLanes({
fromLaneId: source.droppableId, fromLaneId: source.droppableId,
toLaneId: destination.droppableId, toLaneId: destination.droppableId,
cardId: draggableId, cardId: draggableId,
index: destination.index index: destination.index
}) })
); );
try { try {
await onDragEnd({ draggableId, type, source, reason, mode, destination, combine }); await onDragEnd({ draggableId, type, source, reason, mode, destination, combine });
} catch (err) { } catch (err) {
console.error("Error in onLaneDrag", err); console.error("Error in onLaneDrag", err);
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
}
} }
}, },
[dispatch, onDragEnd, setDragTime] [dispatch, onDragEnd, setDragTime]

View File

@@ -120,15 +120,14 @@ const Lane = ({
const Component = orientation === "vertical" ? VirtuosoGrid : Virtuoso; const Component = orientation === "vertical" ? VirtuosoGrid : Virtuoso;
const FinalComponent = collapsed ? "div" : Component; const FinalComponent = collapsed ? "div" : Component;
const commonProps = { const commonProps = {
useWindowScroll: true, data: renderedCards,
data: renderedCards customScrollParent: laneRef.current
}; };
const verticalProps = { const verticalProps = {
...commonProps, ...commonProps,
listClassName: "grid-container", listClassName: "grid-container",
itemClassName: "grid-item", itemClassName: "grid-item",
customScrollParent: laneRef.current,
components: { components: {
List: ListComponent, List: ListComponent,
Item: ItemComponent Item: ItemComponent
@@ -142,7 +141,6 @@ const Lane = ({
components: { Item: HeightPreservingItem }, components: { Item: HeightPreservingItem },
overscan: { main: 3, reverse: 3 }, overscan: { main: 3, reverse: 3 },
itemContent: (index, item) => renderDraggable(index, item), itemContent: (index, item) => renderDraggable(index, item),
scrollerRef: provided.innerRef,
style: { style: {
minWidth: maxCardWidth, minWidth: maxCardWidth,
minHeight: maxLaneHeight minHeight: maxLaneHeight
@@ -180,13 +178,14 @@ const Lane = ({
override={orientation !== "horizontal" && (collapsed || !renderedCards.length)} override={orientation !== "horizontal" && (collapsed || !renderedCards.length)}
> >
<div <div
{...provided.droppableProps} ref={laneRef} // Ensure laneRef is set here
ref={provided.innerRef} style={{ height: "100%", width: "100%" }} // Make it scrollable
className={`react-trello-lane ${collapsed ? "lane-collapsed" : ""}`} className={`react-trello-lane ${collapsed ? "lane-collapsed" : ""}`}
style={{ ...provided.droppableProps.style }}
> >
<FinalComponent {...finalComponentProps} /> <div {...provided.droppableProps} ref={provided.innerRef} style={{ ...provided.droppableProps.style }}>
{shouldRenderPlaceholder && provided.placeholder} <FinalComponent {...finalComponentProps} />
{shouldRenderPlaceholder && provided.placeholder}
</div>
</div> </div>
</HeightMemoryWrapper> </HeightMemoryWrapper>
); );

View File

@@ -54,6 +54,9 @@ export function ProfileShopsContainer({ bodyshop, currentUser }) {
//Force window refresh. //Force window refresh.
//Ping the new partner to refresh.
axios.post("http://localhost:1337/refresh");
window.location.reload(); window.location.reload();
}; };

View File

@@ -1,6 +1,6 @@
import { Button, Col, Form, Input, Row, Select, Space, Switch, Typography } from "antd"; import { Button, Col, Form, Input, Row, Select, Space, Switch, Typography } from "antd";
import axios from "axios"; import axios from "axios";
import React, { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -8,16 +8,16 @@ import { calculateScheduleLoad } from "../../redux/application/application.actio
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import { DateFormatter } from "../../utils/DateFormatter"; import { DateFormatter } from "../../utils/DateFormatter";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import BlurWrapper from "../feature-wrapper/blur-wrapper.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component"; import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component";
import EmailInput from "../form-items-formatted/email-form-item.component"; import EmailInput from "../form-items-formatted/email-form-item.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import ScheduleDayViewContainer from "../schedule-day-view/schedule-day-view.container"; import ScheduleDayViewContainer from "../schedule-day-view/schedule-day-view.container";
import ScheduleExistingAppointmentsList from "../schedule-existing-appointments-list/schedule-existing-appointments-list.component"; import ScheduleExistingAppointmentsList from "../schedule-existing-appointments-list/schedule-existing-appointments-list.component";
import "./schedule-job-modal.scss";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import BlurWrapper from "../feature-wrapper/blur-wrapper.component";
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component"; import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
import "./schedule-job-modal.scss";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
@@ -60,10 +60,12 @@ export function ScheduleJobModalComponent({
const totalHours = const totalHours =
lbrHrsData.jobs_by_pk.labhrs.aggregate.sum.mod_lb_hrs + lbrHrsData.jobs_by_pk.larhrs.aggregate.sum.mod_lb_hrs; lbrHrsData.jobs_by_pk.labhrs.aggregate.sum.mod_lb_hrs + lbrHrsData.jobs_by_pk.larhrs.aggregate.sum.mod_lb_hrs;
if (values.start && !values.scheduled_completion) if (values.start && !values.scheduled_completion) {
form.setFieldsValue({ const addDays = bodyshop.ss_configuration.nobusinessdays
scheduled_completion: dayjs(values.start).businessDaysAdd(totalHours / bodyshop.target_touchtime, "day") ? dayjs(values.start).add(totalHours / (bodyshop.target_touchtime || 1), "day")
}); : dayjs(values.start).businessDaysAdd(totalHours / (bodyshop.target_touchtime || 1), "day");
form.setFieldsValue({ scheduled_completion: addDays });
}
} }
}; };

View File

@@ -1,4 +1,3 @@
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Card, Tabs } from "antd"; import { Button, Card, Tabs } from "antd";
import React from "react"; import React from "react";
@@ -24,6 +23,8 @@ import ShopInfoRoGuard from "./shop-info.roguard.component";
import ShopInfoIntellipay from "./shop-intellipay-config.component"; import ShopInfoIntellipay from "./shop-intellipay-config.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component"; import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import ShopInfoNotificationsAutoadd from "./shop-info.notifications-autoadd.component.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
@@ -41,6 +42,7 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
names: ["CriticalPartsScanning", "Enhanced_Payroll"], names: ["CriticalPartsScanning", "Enhanced_Payroll"],
splitKey: bodyshop.imexshopid splitKey: bodyshop.imexshopid
}); });
const { scenarioNotificationsOn } = useSocket();
const { t } = useTranslation(); const { t } = useTranslation();
const history = useNavigate(); const history = useNavigate();
@@ -137,9 +139,21 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
{ {
key: "intellipay", key: "intellipay",
label: InstanceRenderManager({ rome: t("bodyshop.labels.romepay"), imex: t("bodyshop.labels.imexpay") }), label: InstanceRenderManager({
rome: t("bodyshop.labels.romepay"),
imex: t("bodyshop.labels.imexpay")
}),
children: <ShopInfoIntellipay form={form} /> children: <ShopInfoIntellipay form={form} />
} },
...(scenarioNotificationsOn
? [
{
key: "notifications_autoadd",
label: t("bodyshop.labels.notifications.followers"),
children: <ShopInfoNotificationsAutoadd form={form} bodyshop={bodyshop} />
}
]
: [])
]; ];
return ( return (
<Card <Card

View File

@@ -906,6 +906,7 @@ export function ShopInfoGeneral({ form, bodyshop }) {
add(); add();
}} }}
style={{ width: "100%" }} style={{ width: "100%" }}
id="insurancecos-add-button"
> >
{t("general.actions.add")} {t("general.actions.add")}
</Button> </Button>

View File

@@ -0,0 +1,57 @@
import { Form, Typography } from "antd";
import { useTranslation } from "react-i18next";
import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component.jsx";
const { Text, Paragraph } = Typography;
export default function ShopInfoNotificationsAutoadd({ bodyshop }) {
const { t } = useTranslation();
// Filter employee options to ensure active employees with valid IDs
const employeeOptions = bodyshop?.employees?.filter((e) => e.active && e.id && typeof e.id === "string") || [];
return (
<div>
<Paragraph>{t("bodyshop.fields.notifications.description")}</Paragraph>
<Text type="secondary">{t("bodyshop.labels.notifications.followers")}</Text>
{employeeOptions.length > 0 ? (
<Form.Item
name="notification_followers"
rules={[
{
type: "array",
message: t("general.validation.array")
},
{
validator: async (_, value) => {
if (!value || value.length === 0) {
return Promise.resolve(); // Allow empty array
}
const hasInvalid = value.some((id) => id == null || typeof id !== "string" || id.trim() === "");
if (hasInvalid) {
return Promise.reject(new Error(t("bodyshop.fields.notifications.invalid_followers")));
}
return Promise.resolve();
}
}
]}
>
<EmployeeSearchSelectComponent
style={{ minWidth: "100%" }}
mode="multiple"
options={employeeOptions}
placeholder={t("bodyshop.fields.notifications.placeholder")}
showEmail={true}
onChange={(value) => {
// Filter out null or invalid values before passing to Form
const cleanedValue = value?.filter((id) => id != null && typeof id === "string" && id.trim() !== "");
return cleanedValue;
}}
/>
</Form.Item>
) : (
<Text type="secondary">{t("bodyshop.fields.no_employees_available")}</Text>
)}
</div>
);
}

View File

@@ -1,16 +1,15 @@
import { DeleteFilled } from "@ant-design/icons"; import { DeleteFilled } from "@ant-design/icons";
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch, TimePicker } from "antd"; import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch, TimePicker } from "antd";
import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import ColorpickerFormItemComponent from "../form-items-formatted/colorpicker-form-item.component"; import ColorpickerFormItemComponent from "../form-items-formatted/colorpicker-form-item.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component"; import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { ColorPicker } from "./shop-info.rostatus.component"; import { ColorPicker } from "./shop-info.rostatus.component";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
@@ -78,6 +77,13 @@ export function ShopInfoSchedulingComponent({ form, bodyshop }) {
> >
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </Form.Item>
<Form.Item
name={["ss_configuration", "nobusinessdays"]}
label={t("bodyshop.fields.ss_configuration.nobusinessdays")}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item <Form.Item
name={["md_lost_sale_reasons"]} name={["md_lost_sale_reasons"]}
label={t("bodyshop.fields.md_lost_sale_reasons")} label={t("bodyshop.fields.md_lost_sale_reasons")}

View File

@@ -25,23 +25,6 @@ export function ShopTemplateTestRender({ bodyshop, query, emailEditorRef, style
emailEditorRef.current.exportHtml(async (data) => { emailEditorRef.current.exportHtml(async (data) => {
try { try {
// const inlineHtml = await axios.post("/render/inlinecss", {
// html: data.html,
// url: `${window.location.protocol}://${window.location.host}/`,
// });
// const { data: contextData } = await client.query({
// query: gql(query),
// variables: variables,
//
// });
// const renderResponse = await axios.post("/render", {
// view: inlineHtml.data,
// context: { ...contextData, bodyshop: bodyshop },
// });
// displayTemplateInWindowNoprint(renderResponse.data);
setLoading(false); setLoading(false);
} catch (error) { } catch (error) {
setLoading(false); setLoading(false);

View File

@@ -1,7 +1,6 @@
import { useLazyQuery } from "@apollo/client"; import { useLazyQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Form, Input, InputNumber, Select, Switch } from "antd"; import { Card, Form, Input, InputNumber, Select, Space, Switch } from "antd";
import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -19,6 +18,7 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component"; import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component"; import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component";
import TimeTicketList from "../time-ticket-list/time-ticket-list.component"; import TimeTicketList from "../time-ticket-list/time-ticket-list.component";
import JobEmployeeAssignmentsContainer from "./../job-employee-assignments/job-employee-assignments.container";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -319,10 +319,15 @@ export function TimeTicketModalComponent({
} }
export function LaborAllocationContainer({ jobid, loading, lineTicketData, hideTimeTickets = false }) { export function LaborAllocationContainer({ jobid, loading, lineTicketData, hideTimeTickets = false }) {
const { t } = useTranslation();
if (loading) return <LoadingSkeleton />; if (loading) return <LoadingSkeleton />;
if (!lineTicketData) return null; if (!lineTicketData) return null;
if (!jobid) return null;
return ( return (
<div> <Space direction="vertical" style={{ width: "100%" }}>
<Card style={{ height: "100%" }} title={t("jobs.labels.employeeassignments")}>
<JobEmployeeAssignmentsContainer job={lineTicketData.jobs_by_pk} />
</Card>
<LaborAllocationsTable <LaborAllocationsTable
jobId={jobid} jobId={jobid}
joblines={lineTicketData.joblines} joblines={lineTicketData.joblines}
@@ -332,6 +337,6 @@ export function LaborAllocationContainer({ jobid, loading, lineTicketData, hideT
{!hideTimeTickets && ( {!hideTimeTickets && (
<TimeTicketList loading={loading} timetickets={jobid ? lineTicketData.timetickets : []} techConsole /> <TimeTicketList loading={loading} timetickets={jobid ? lineTicketData.timetickets : []} techConsole />
)} )}
</div> </Space>
); );
} }

View File

@@ -2,10 +2,11 @@ import { PageHeader } from "@ant-design/pro-layout";
import { useMutation, useQuery } from "@apollo/client"; import { useMutation, useQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Form, Modal, Space } from "antd"; import { Button, Form, Modal, Space } from "antd";
import React, { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { QUERY_ACTIVE_EMPLOYEES } from "../../graphql/employees.queries"; import { QUERY_ACTIVE_EMPLOYEES } from "../../graphql/employees.queries";
import { INSERT_NEW_TIME_TICKET, UPDATE_TIME_TICKET } from "../../graphql/timetickets.queries"; import { INSERT_NEW_TIME_TICKET, UPDATE_TIME_TICKET } from "../../graphql/timetickets.queries";
import { toggleModalVisible } from "../../redux/modals/modals.actions"; import { toggleModalVisible } from "../../redux/modals/modals.actions";
@@ -14,7 +15,6 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time-tickets-commit-toggle.component"; import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time-tickets-commit-toggle.component";
import TimeTicketModalComponent from "./time-ticket-modal.component"; import TimeTicketModalComponent from "./time-ticket-modal.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
timeTicketModal: selectTimeTicket, timeTicketModal: selectTimeTicket,
@@ -81,7 +81,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
} }
}; };
const handleMutationSuccess = (response) => { const handleMutationSuccess = () => {
notification["success"]({ notification["success"]({
message: t("timetickets.successes.created") message: t("timetickets.successes.created")
}); });
@@ -123,7 +123,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
if (timeTicketModal.open) form.resetFields(); if (timeTicketModal.open) form.resetFields();
}, [timeTicketModal.open, form]); }, [timeTicketModal.open, form]);
const handleFieldsChange = (changedFields, allFields) => { const handleFieldsChange = (changedFields) => {
if (!!changedFields.employeeid && !!EmployeeAutoCompleteData) { if (!!changedFields.employeeid && !!EmployeeAutoCompleteData) {
const emps = EmployeeAutoCompleteData.employees.filter((e) => e.id === changedFields.employeeid); const emps = EmployeeAutoCompleteData.employees.filter((e) => e.id === changedFields.employeeid);
form.setFieldsValue({ form.setFieldsValue({
@@ -182,6 +182,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
</Space> </Space>
} }
destroyOnClose destroyOnClose
id="time-ticket-modal"
> >
<Form <Form
onFinish={handleFinish} onFinish={handleFinish}

View File

@@ -113,7 +113,7 @@ export function UpdateAlert({ updateAvailable }) {
</Col> </Col>
<Col sm={24} md={8} lg={6}> <Col sm={24} md={8} lg={6}>
<Space wrap> <Space wrap>
<Button onClick={() => window.open("https://imex-online.noticeable.news/", "_blank")}> <Button onClick={() => window.open("https://shopmanagement.canny.io/changelog", "_blank")}>
{i18n.t("general.actions.viewreleasenotes")} {i18n.t("general.actions.viewreleasenotes")}
</Button> </Button>
<Button loading={loading} type="primary" onClick={() => ReloadNewVersion()}> <Button loading={loading} type="primary" onClick={() => ReloadNewVersion()}>

View File

@@ -57,6 +57,7 @@ export const QUERY_BODYSHOP = gql`
logo_img_path logo_img_path
md_ro_statuses md_ro_statuses
md_order_statuses md_order_statuses
tours_enabled
md_functionality_toggles md_functionality_toggles
shopname shopname
state state
@@ -140,6 +141,7 @@ export const QUERY_BODYSHOP = gql`
use_paint_scale_data use_paint_scale_data
intellipay_config intellipay_config
md_ro_guard md_ro_guard
notification_followers
employee_teams(order_by: { name: asc }, where: { active: { _eq: true } }) { employee_teams(order_by: { name: asc }, where: { active: { _eq: true } }) {
id id
name name
@@ -186,6 +188,7 @@ export const UPDATE_SHOP = gql`
phone phone
federal_tax_id federal_tax_id
id id
tours_enabled
insurance_vendor_id insurance_vendor_id
logo_img_path logo_img_path
md_ro_statuses md_ro_statuses
@@ -269,6 +272,7 @@ export const UPDATE_SHOP = gql`
md_tasks_presets md_tasks_presets
intellipay_config intellipay_config
md_ro_guard md_ro_guard
notification_followers
employee_teams(order_by: { name: asc }, where: { active: { _eq: true } }) { employee_teams(order_by: { name: asc }, where: { active: { _eq: true } }) {
id id
name name

View File

@@ -35,6 +35,30 @@ export const GET_LINE_TICKET_BY_PK = gql`
lbr_adjustments lbr_adjustments
converted converted
status status
employee_body
employee_body_rel {
id
first_name
last_name
}
employee_csr
employee_csr_rel {
id
first_name
last_name
}
employee_prep
employee_prep_rel {
id
first_name
last_name
}
employee_refinish
employee_refinish_rel {
id
first_name
last_name
}
} }
joblines(where: { jobid: { _eq: $id }, removed: { _eq: false } }) { joblines(where: { jobid: { _eq: $id }, removed: { _eq: false } }) {
id id

View File

@@ -423,6 +423,7 @@ export const GET_JOB_BY_PK = gql`
actual_completion actual_completion
actual_delivery actual_delivery
actual_in actual_in
acv_amount
adjustment_bottom_line adjustment_bottom_line
alt_transport alt_transport
area_of_damage area_of_damage
@@ -511,6 +512,7 @@ export const GET_JOB_BY_PK = gql`
est_ph1 est_ph1
flat_rate_ats flat_rate_ats
federal_tax_rate federal_tax_rate
hit_and_run
id id
inproduction inproduction
ins_addr1 ins_addr1
@@ -683,6 +685,8 @@ export const GET_JOB_BY_PK = gql`
scheduled_delivery scheduled_delivery
scheduled_in scheduled_in
selling_dealer selling_dealer
estimate_approved
estimate_sent_approval
selling_dealer_contact selling_dealer_contact
servicing_dealer servicing_dealer
servicing_dealer_contact servicing_dealer_contact
@@ -927,6 +931,8 @@ export const QUERY_JOB_CARD_DETAILS = gql`
date_exported date_exported
date_repairstarted date_repairstarted
date_scheduled date_scheduled
estimate_sent_approval
estimate_approved
date_estimated date_estimated
employee_body_rel { employee_body_rel {
id id
@@ -1075,6 +1081,8 @@ export const UPDATE_JOB = gql`
date_repairstarted date_repairstarted
date_void date_void
date_lost_sale date_lost_sale
estimate_sent_approval
estimate_approved
} }
} }
} }
@@ -2429,6 +2437,8 @@ export const QUERY_PARTS_QUEUE_CARD_DETAILS = gql`
plate_st plate_st
po_number po_number
production_vars production_vars
estimate_sent_approval
estimate_approved
ro_number ro_number
scheduled_completion scheduled_completion
scheduled_delivery scheduled_delivery
@@ -2570,6 +2580,20 @@ export const GET_JOB_BY_PK_QUICK_INTAKE = gql`
actual_completion actual_completion
scheduled_delivery scheduled_delivery
actual_delivery actual_delivery
labhrs: joblines_aggregate(where: { _and: [{ mod_lbr_ty: { _neq: "LAR" } }, { removed: { _eq: false } }] }) {
aggregate {
sum {
mod_lb_hrs
}
}
}
larhrs: joblines_aggregate(where: { _and: [{ mod_lbr_ty: { _eq: "LAR" } }, { removed: { _eq: false } }] }) {
aggregate {
sum {
mod_lb_hrs
}
}
}
} }
} }
`; `;

View File

@@ -78,6 +78,9 @@ export const QUERY_PARTS_ORDER_OEC = gql`
} }
ro_number ro_number
clm_no clm_no
cieca_stl
cieca_ttl
cieca_pfl
asgn_no asgn_no
asgn_date asgn_date
state_tax_rate state_tax_rate
@@ -164,6 +167,7 @@ export const QUERY_PARTS_ORDER_OEC = gql`
loss_desc loss_desc
loss_of_use loss_of_use
loss_type loss_type
materials
ownr_addr1 ownr_addr1
ownr_addr2 ownr_addr2
ownr_city ownr_city

View File

@@ -91,6 +91,7 @@ export const QUERY_NOTIFICATION_SETTINGS = gql`
associations(where: { _and: { useremail: { _eq: $email }, active: { _eq: true } } }) { associations(where: { _and: { useremail: { _eq: $email }, active: { _eq: true } } }) {
id id
notification_settings notification_settings
notifications_autoadd
} }
} }
`; `;
@@ -103,3 +104,12 @@ export const UPDATE_NOTIFICATION_SETTINGS = gql`
} }
} }
`; `;
export const UPDATE_NOTIFICATIONS_AUTOADD = gql`
mutation UPDATE_NOTIFICATIONS_AUTOADD($id: uuid!, $autoadd: Boolean!) {
update_associations_by_pk(pk_columns: { id: $id }, _set: { notifications_autoadd: $autoadd }) {
id
notifications_autoadd
}
}
`;

View File

@@ -0,0 +1,42 @@
import axios from "axios";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
const mapDispatchToProps = (dispatch) => ({
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
});
export function FeedbackPage({ setBreadcrumbs, setSelectedHeader }) {
const { t } = useTranslation();
useEffect(() => {
document.title = t("titles.feature-request", {
app: InstanceRenderManager({
imex: "$t(titles.imexonline)",
rome: "$t(titles.romeonline)"
})
});
setBreadcrumbs([{ link: "/manage/feature-request", label: t("titles.bc.feature-request") }]);
}, [t, setBreadcrumbs, setSelectedHeader]);
useEffect(() => {
async function RenderCanny() {
const ssoToken = await axios.post("/sso/canny");
window.Canny("render", {
boardToken: "bba97b06-70db-0334-dee7-8108d73ef614",
basePath: `/manage/feature-request`, // See step 2
ssoToken: ssoToken.data, // See step 3,
theme: "light" // options: light [default], dark, auto
});
}
RenderCanny();
}, []);
return <div data-canny />;
}
export default connect(null, mapDispatchToProps)(FeedbackPage);

View File

@@ -1,4 +1,5 @@
import { FloatButton, Layout, Spin } from "antd"; import { Button, FloatButton, Layout, Space, Spin } from "antd";
import { AlertOutlined, BulbOutlined } from "@ant-design/icons";
// import preval from "preval.macro"; // import preval from "preval.macro";
import React, { lazy, Suspense, useEffect, useState } from "react"; import React, { lazy, Suspense, useEffect, useState } from "react";
@@ -19,7 +20,6 @@ import LoadingSpinner from "../../components/loading-spinner/loading-spinner.com
import PartnerPingComponent from "../../components/partner-ping/partner-ping.component"; import PartnerPingComponent from "../../components/partner-ping/partner-ping.component";
import PrintCenterModalContainer from "../../components/print-center-modal/print-center-modal.container"; import PrintCenterModalContainer from "../../components/print-center-modal/print-center-modal.container";
import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component"; import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component";
import { requestForToken } from "../../firebase/firebase.utils";
import { selectBodyshop, selectInstanceConflict } from "../../redux/user/user.selectors"; import { selectBodyshop, selectInstanceConflict } from "../../redux/user/user.selectors";
import UpdateAlert from "../../components/update-alert/update-alert.component"; import UpdateAlert from "../../components/update-alert/update-alert.component";
import InstanceRenderManager from "../../utils/instanceRenderMgr.js"; import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
@@ -56,6 +56,7 @@ const ContractCreatePage = lazy(() => import("../contract-create/contract-create
const ContractDetailPage = lazy(() => import("../contract-detail/contract-detail.page.container")); const ContractDetailPage = lazy(() => import("../contract-detail/contract-detail.page.container"));
const ContractsList = lazy(() => import("../contracts/contracts.page.container")); const ContractsList = lazy(() => import("../contracts/contracts.page.container"));
const BillsListPage = lazy(() => import("../bills/bills.page.container")); const BillsListPage = lazy(() => import("../bills/bills.page.container"));
const FeatureRequestPage = lazy(() => import("../feature-request/feature-request.page.jsx"));
const JobCostingModal = lazy(() => import("../../components/job-costing-modal/job-costing-modal.container")); const JobCostingModal = lazy(() => import("../../components/job-costing-modal/job-costing-modal.container"));
const ReportCenterModal = lazy(() => import("../../components/report-center-modal/report-center-modal.container")); const ReportCenterModal = lazy(() => import("../../components/report-center-modal/report-center-modal.container"));
@@ -180,15 +181,12 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
}); });
} }
}, [alerts, displayedAlertIds, notification]); }, [alerts, displayedAlertIds, notification]);
useEffect(() => { useEffect(() => {
const widgetId = InstanceRenderManager({ window.Canny("initChangelog", {
imex: "IABVNO4scRKY11XBQkNr", appID: "680bd2c7ee501290377f6686",
rome: "mQdqARMzkZRUVugJ6TdS" position: "top",
}); align: "left",
window.noticeable.render("widget", widgetId); theme: "light" // options: light [default], dark, auto
requestForToken().catch((error) => {
console.error(`Unable to request for token.`, error);
}); });
}, []); }, []);
@@ -480,6 +478,8 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
// element={<ShopTemplates />} // element={<ShopTemplates />}
// /> // />
} }
<Route path="/feature-request/*" index element={<FeatureRequestPage />} />
<Route <Route
path="/shop/vendors" path="/shop/vendors"
element={ element={
@@ -669,7 +669,12 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
margin: "1rem 0rem" margin: "1rem 0rem"
}} }}
> >
<div style={{ display: "flex" }}> <Link to="/manage/feature-request">
<Button icon={<BulbOutlined />} type="text">
{t("general.labels.feature-request")}
</Button>
</Link>
<Space>
<WssStatusDisplayComponent /> <WssStatusDisplayComponent />
<div onClick={broadcastMessage}> <div onClick={broadcastMessage}>
{`${InstanceRenderManager({ {`${InstanceRenderManager({
@@ -677,8 +682,10 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
rome: t("titles.romeonline") rome: t("titles.romeonline")
})} - ${import.meta.env.VITE_APP_GIT_SHA_DATE}`} })} - ${import.meta.env.VITE_APP_GIT_SHA_DATE}`}
</div> </div>
<div id="noticeable-widget" style={{ marginLeft: "1rem" }} /> <Button icon={<AlertOutlined />} data-canny-changelog type="text">
</div> {t("general.labels.changelog")}
</Button>
</Space>
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}> <Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
Disclaimer & Notices Disclaimer & Notices
</Link> </Link>

View File

@@ -1,7 +1,4 @@
import FingerprintJS from "@fingerprintjs/fingerprintjs"; import FingerprintJS from "@fingerprintjs/fingerprintjs";
import * as Sentry from "@sentry/browser";
import { notification } from "antd";
import axios from "axios";
import { setUserId, setUserProperties } from "@firebase/analytics"; import { setUserId, setUserProperties } from "@firebase/analytics";
import { import {
checkActionCode, checkActionCode,
@@ -12,6 +9,9 @@ import {
} from "@firebase/auth"; } from "@firebase/auth";
import { arrayUnion, doc, getDoc, setDoc, updateDoc } from "@firebase/firestore"; import { arrayUnion, doc, getDoc, setDoc, updateDoc } from "@firebase/firestore";
import { getToken } from "@firebase/messaging"; import { getToken } from "@firebase/messaging";
import * as Sentry from "@sentry/react";
import { notification } from "antd";
import axios from "axios";
import i18next from "i18next"; import i18next from "i18next";
import LogRocket from "logrocket"; import LogRocket from "logrocket";
import { all, call, delay, put, select, takeLatest } from "redux-saga/effects"; import { all, call, delay, put, select, takeLatest } from "redux-saga/effects";
@@ -351,7 +351,14 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
}); });
payload.features?.allAccess === true payload.features?.allAccess === true
? window.$crisp.push(["set", "session:segments", [["allAccess"]]]) ? window.$crisp.push(["set", "session:segments", [["allAccess"]]])
: window.$crisp.push(["set", "session:segments", [["basic"]]]); : (() => {
const featureKeys = Object.keys(payload.features).filter(
(key) =>
payload.features[key] === true ||
(typeof payload.features[key] === "string" && !isNaN(Date.parse(payload.features[key])))
);
window.$crisp.push(["set", "session:segments", [["basic", ...featureKeys]]]);
})();
} catch (error) { } catch (error) {
console.warn("Couldnt find $crisp.", error.message); console.warn("Couldnt find $crisp.", error.message);
} }

View File

@@ -335,7 +335,6 @@
"intellipay_config": { "intellipay_config": {
"cash_discount_percentage": "Cash Discount %", "cash_discount_percentage": "Cash Discount %",
"enable_cash_discount": "Enable Cash Discounting", "enable_cash_discount": "Enable Cash Discounting",
"payment_type": "Payment Type Map",
"payment_map": { "payment_map": {
"amex": "American Express", "amex": "American Express",
"disc": "Discover", "disc": "Discover",
@@ -344,7 +343,8 @@
"jcb": "JCB", "jcb": "JCB",
"mast": "MasterCard", "mast": "MasterCard",
"visa": "Visa" "visa": "Visa"
} },
"payment_type": "Payment Type Map"
}, },
"invoice_federal_tax_rate": "Invoices - Federal Tax Rate", "invoice_federal_tax_rate": "Invoices - Federal Tax Rate",
"invoice_local_tax_rate": "Invoices - Local Tax Rate", "invoice_local_tax_rate": "Invoices - Local Tax Rate",
@@ -601,7 +601,8 @@
"templates": "Templates" "templates": "Templates"
}, },
"ss_configuration": { "ss_configuration": {
"dailyhrslimit": "Daily Incoming Hours Limit" "dailyhrslimit": "Daily Incoming Hours Limit",
"nobusinessdays": "Include Weekends"
}, },
"ssbuckets": { "ssbuckets": {
"color": "Job Color", "color": "Job Color",
@@ -647,7 +648,12 @@
"use_paint_scale_data": "Use Paint Scale Data for Job Costing?", "use_paint_scale_data": "Use Paint Scale Data for Job Costing?",
"uselocalmediaserver": "Use Local Media Server?", "uselocalmediaserver": "Use Local Media Server?",
"website": "Website", "website": "Website",
"zip_post": "Zip/Postal Code" "zip_post": "Zip/Postal Code",
"notifications": {
"description": "Select employees to automatically follow new jobs and receive notifications for job updates.",
"placeholder": "Search for employees",
"invalid_followers": "Invalid selection. Please select valid employees."
}
}, },
"labels": { "labels": {
"2tiername": "Name => RO", "2tiername": "Name => RO",
@@ -727,7 +733,10 @@
"ssbuckets": "Job Size Definitions", "ssbuckets": "Job Size Definitions",
"systemsettings": "System Settings", "systemsettings": "System Settings",
"task-presets": "Task Presets", "task-presets": "Task Presets",
"workingdays": "Working Days" "workingdays": "Working Days",
"notifications": {
"followers": "Notifications"
}
}, },
"operations": { "operations": {
"contains": "Contains", "contains": "Contains",
@@ -1220,7 +1229,8 @@
"errors": { "errors": {
"fcm": "You must allow notification permissions to have real time messaging. Click to try again.", "fcm": "You must allow notification permissions to have real time messaging. Click to try again.",
"notfound": "No record was found.", "notfound": "No record was found.",
"sizelimit": "The selected items exceed the size limit." "sizelimit": "The selected items exceed the size limit.",
"submit-for-testing": "Error submitting Job for testing."
}, },
"itemtypes": { "itemtypes": {
"contract": "CC Contract", "contract": "CC Contract",
@@ -1234,6 +1244,7 @@
"areyousure": "Are you sure?", "areyousure": "Are you sure?",
"barcode": "Barcode", "barcode": "Barcode",
"cancel": "Are you sure you want to cancel? Your changes will not be saved.", "cancel": "Are you sure you want to cancel? Your changes will not be saved.",
"changelog": "Change Log",
"clear": "Clear", "clear": "Clear",
"confirmpassword": "Confirm Password", "confirmpassword": "Confirm Password",
"created_at": "Created At", "created_at": "Created At",
@@ -1243,6 +1254,7 @@
"errors": "Errors", "errors": "Errors",
"excel": "Excel", "excel": "Excel",
"exceptiontitle": "An error has occurred.", "exceptiontitle": "An error has occurred.",
"feature-request": "Have a feature request?",
"friday": "Friday", "friday": "Friday",
"globalsearch": "Global Search", "globalsearch": "Global Search",
"help": "Help", "help": "Help",
@@ -1321,6 +1333,7 @@
"notfoundtitle": "We couldn't find what you're looking for...", "notfoundtitle": "We couldn't find what you're looking for...",
"partnernotrunning": "{{app}} has detected that the partner is not running. Please ensure it is running to enable full functionality.", "partnernotrunning": "{{app}} has detected that the partner is not running. Please ensure it is running to enable full functionality.",
"rbacunauth": "You are not authorized to view this content. Please reach out to your shop manager to change your access level.", "rbacunauth": "You are not authorized to view this content. Please reach out to your shop manager to change your access level.",
"submit-for-testing": "Submitted Job for testing successfully.",
"unsavedchanges": "You have unsaved changes.", "unsavedchanges": "You have unsaved changes.",
"unsavedchangespopup": "You have unsaved changes. Are you sure you want to leave?" "unsavedchangespopup": "You have unsaved changes. Are you sure you want to leave?"
}, },
@@ -1633,9 +1646,12 @@
"actual_completion": "Actual Completion", "actual_completion": "Actual Completion",
"actual_delivery": "Actual Delivery", "actual_delivery": "Actual Delivery",
"actual_in": "Actual In", "actual_in": "Actual In",
"acv_amount": "ACV Amount",
"adjustment_bottom_line": "Adjustments", "adjustment_bottom_line": "Adjustments",
"adjustmenthours": "Adjustment Hours", "adjustmenthours": "Adjustment Hours",
"alt_transport": "Alt. Trans.", "alt_transport": "Alt. Trans.",
"estimate_sent_approval": "Estimate Sent for Approval",
"estimate_approved": "Estimate Approved",
"area_of_damage_impact": { "area_of_damage_impact": {
"10": "Left Front Side", "10": "Left Front Side",
"11": "Left Front Corner", "11": "Left Front Corner",
@@ -1758,9 +1774,10 @@
"est_ct_ln": "Estimator Last Name", "est_ct_ln": "Estimator Last Name",
"est_ea": "Estimator Email", "est_ea": "Estimator Email",
"est_ph1": "Estimator Phone #", "est_ph1": "Estimator Phone #",
"flat_rate_ats": "Flat Rate ATS?",
"federal_tax_payable": "Federal Tax Payable", "federal_tax_payable": "Federal Tax Payable",
"federal_tax_rate": "Federal Tax Rate", "federal_tax_rate": "Federal Tax Rate",
"flat_rate_ats": "Flat Rate ATS?",
"hit_and_run": "Hit and Run",
"ins_addr1": "Insurance Co. Address", "ins_addr1": "Insurance Co. Address",
"ins_city": "Insurance Co. City", "ins_city": "Insurance Co. City",
"ins_co_id": "Insurance Co. ID", "ins_co_id": "Insurance Co. ID",
@@ -1940,6 +1957,8 @@
"scheddates": "Schedule Dates" "scheddates": "Schedule Dates"
}, },
"labels": { "labels": {
"sent": "",
"approved": "",
"accountsreceivable": "Accounts Receivable", "accountsreceivable": "Accounts Receivable",
"act_price_ppc": "New Part Price", "act_price_ppc": "New Part Price",
"actual_completion_inferred": "$t(jobs.fields.actual_completion) inferred using $t(jobs.fields.scheduled_completion).", "actual_completion_inferred": "$t(jobs.fields.actual_completion) inferred using $t(jobs.fields.scheduled_completion).",
@@ -2314,6 +2333,7 @@
"duplicate": "Duplicate this Job", "duplicate": "Duplicate this Job",
"duplicatenolines": "Duplicate this Job without Repair Data", "duplicatenolines": "Duplicate this Job without Repair Data",
"newcccontract": "Create Courtesy Car Contract", "newcccontract": "Create Courtesy Car Contract",
"submit-for-testing": "Submit for Testing",
"void": "Void Job" "void": "Void Job"
}, },
"jobsdetail": { "jobsdetail": {
@@ -2420,6 +2440,63 @@
"updated": "Note updated successfully." "updated": "Note updated successfully."
} }
}, },
"notifications": {
"actions": {
"remove": "Remove"
},
"aria": {
"toggle": "Toggle Watching Job"
},
"channels": {
"app": "App",
"email": "Email",
"fcm": "Push"
},
"labels": {
"auto-add": "Automatically watch Jobs I import",
"auto-add-success": "Auto watcher status successfully changed.",
"auto-add-failure": "Something went wrong updating your auto watcher status.",
"add-watchers": "Add Watchers",
"add-watchers-team": "Add Team Members",
"employee-search": "Search for an Employee",
"mark-all-read": "Mark All Read",
"new-notification-title": "New Notification:",
"no-watchers": "No Watchers",
"notification-center": "Notification Center",
"notification-popup-title": "Changes for Job #{{ro_number}}",
"notification-settings-failure": "Error saving Notification Settings. {{error}}",
"notification-settings-success": "Notification Settings saved successfully.",
"notificationscenarios": "Job Notification Scenarios",
"ro-number": "RO #{{ro_number}}",
"save": "Save Scenarios",
"scenario": "Scenario",
"show-unread-only": "Show Unread Only",
"teams-search": "Search for a Team",
"unwatch": "Unwatch",
"watch": "Watch",
"watching-issue": "Watching"
},
"scenarios": {
"alternate-transport-changed": "Alternate Transport Changed",
"bill-posted": "Bill Posted",
"critical-parts-status-changed": "Critical Parts Status Changed",
"intake-delivery-checklist-completed": "Intake or Delivery Checklist Completed",
"job-added-to-production": "Job Added to Production",
"job-assigned-to-me": "Job Assigned to Me",
"job-status-change": "Job Status Changed",
"new-media-added-reassigned": "New Media Added or Reassigned",
"new-note-added": "New Note Added",
"new-time-ticket-posted": "New Time Ticket Posted",
"part-marked-back-ordered": "Part Marked Back Ordered",
"payment-collected-completed": "Payment Collected / Completed",
"schedule-dates-changed": "Schedule Dates Changed",
"supplement-imported": "Supplement Imported",
"tasks-updated-created": "Tasks Updated / Created"
},
"tooltips": {
"job-watchers": "Job Watchers"
}
},
"owner": { "owner": {
"labels": { "labels": {
"noownerinfo": "No owner information." "noownerinfo": "No owner information."
@@ -3416,6 +3493,7 @@
"dashboard": "Dashboard", "dashboard": "Dashboard",
"dms": "DMS Export", "dms": "DMS Export",
"export-logs": "Export Logs", "export-logs": "Export Logs",
"feature-request": "Feature Requet",
"inventory": "Inventory", "inventory": "Inventory",
"jobs": "Jobs", "jobs": "Jobs",
"jobs-active": "Active Jobs", "jobs-active": "Active Jobs",
@@ -3460,6 +3538,7 @@
"dashboard": "Dashboard | {{app}}", "dashboard": "Dashboard | {{app}}",
"dms": "DMS Export | {{app}}", "dms": "DMS Export | {{app}}",
"export-logs": "Export Logs | {{app}}", "export-logs": "Export Logs | {{app}}",
"feature-request": "Feature Request | {{app}}",
"imexonline": "ImEX Online", "imexonline": "ImEX Online",
"inventory": "Inventory | {{app}}", "inventory": "Inventory | {{app}}",
"jobs": "Active Jobs | {{app}}", "jobs": "Active Jobs | {{app}}",
@@ -3677,10 +3756,10 @@
"users": { "users": {
"errors": { "errors": {
"signinerror": { "signinerror": {
"auth/invalid-email": "A user with this email does not exist.",
"auth/user-disabled": "User account disabled. ", "auth/user-disabled": "User account disabled. ",
"auth/user-not-found": "A user with this email does not exist.", "auth/user-not-found": "A user with this email does not exist.",
"auth/wrong-password": "The email and password combination you provided is incorrect.", "auth/wrong-password": "The email and password combination you provided is incorrect."
"auth/invalid-email": "A user with this email does not exist."
} }
} }
}, },
@@ -3780,60 +3859,6 @@
"validation": { "validation": {
"unique_vendor_name": "You must enter a unique vendor name." "unique_vendor_name": "You must enter a unique vendor name."
} }
},
"notifications": {
"labels": {
"notification-center": "Notification Center",
"scenario": "Scenario",
"notificationscenarios": "Job Notification Scenarios",
"save": "Save Scenarios",
"watching-issue": "Watching",
"add-watchers": "Add Watchers",
"employee-search": "Search for an Employee",
"teams-search": "Search for a Team",
"add-watchers-team": "Add Team Members",
"new-notification-title": "New Notification:",
"show-unread-only": "Show Unread Only",
"mark-all-read": "Mark All Read",
"notification-popup-title": "Changes for Job #{{ro_number}}",
"ro-number": "RO #{{ro_number}}",
"no-watchers": "No Watchers",
"notification-settings-success": "Notification Settings saved successfully.",
"notification-settings-failure": "Error saving Notification Settings. {{error}}",
"watch": "Watch",
"unwatch": "Unwatch"
},
"actions": {
"remove": "Remove"
},
"aria": {
"toggle": "Toggle Watching Job"
},
"tooltips": {
"job-watchers": "Job Watchers"
},
"scenarios": {
"job-assigned-to-me": "Job Assigned to Me",
"bill-posted": "Bill Posted",
"critical-parts-status-changed": "Critical Parts Status Changed",
"part-marked-back-ordered": "Part Marked Back Ordered",
"new-note-added": "New Note Added",
"supplement-imported": "Supplement Imported",
"schedule-dates-changed": "Schedule Dates Changed",
"tasks-updated-created": "Tasks Updated / Created",
"new-media-added-reassigned": "New Media Added or Reassigned",
"new-time-ticket-posted": "New Time Ticket Posted",
"intake-delivery-checklist-completed": "Intake or Delivery Checklist Completed",
"job-added-to-production": "Job Added to Production",
"job-status-change": "Job Status Changed",
"payment-collected-completed": "Payment Collected / Completed",
"alternate-transport-changed": "Alternate Transport Changed"
},
"channels": {
"app": "App",
"email": "Email",
"fcm": "Push"
}
} }
} }
} }

View File

@@ -335,7 +335,6 @@
"intellipay_config": { "intellipay_config": {
"cash_discount_percentage": "", "cash_discount_percentage": "",
"enable_cash_discount": "", "enable_cash_discount": "",
"payment_type": "",
"payment_map": { "payment_map": {
"amex": "American Express", "amex": "American Express",
"disc": "Discover", "disc": "Discover",
@@ -344,7 +343,8 @@
"jcb": "JCB", "jcb": "JCB",
"mast": "MasterCard", "mast": "MasterCard",
"visa": "Visa" "visa": "Visa"
} },
"payment_type": ""
}, },
"invoice_federal_tax_rate": "", "invoice_federal_tax_rate": "",
"invoice_local_tax_rate": "", "invoice_local_tax_rate": "",
@@ -601,7 +601,8 @@
"templates": "" "templates": ""
}, },
"ss_configuration": { "ss_configuration": {
"dailyhrslimit": "" "dailyhrslimit": "",
"nobusinessdays": ""
}, },
"ssbuckets": { "ssbuckets": {
"color": "", "color": "",
@@ -647,7 +648,12 @@
"use_paint_scale_data": "", "use_paint_scale_data": "",
"uselocalmediaserver": "", "uselocalmediaserver": "",
"website": "", "website": "",
"zip_post": "" "zip_post": "",
"notifications": {
"description": "",
"placeholder": "",
"invalid_followers": ""
}
}, },
"labels": { "labels": {
"2tiername": "", "2tiername": "",
@@ -727,7 +733,10 @@
"ssbuckets": "", "ssbuckets": "",
"systemsettings": "", "systemsettings": "",
"task-presets": "", "task-presets": "",
"workingdays": "" "workingdays": "",
"notifications": {
"followers": ""
}
}, },
"operations": { "operations": {
"contains": "", "contains": "",
@@ -1220,7 +1229,8 @@
"errors": { "errors": {
"fcm": "", "fcm": "",
"notfound": "", "notfound": "",
"sizelimit": "" "sizelimit": "",
"submit-for-testing": ""
}, },
"itemtypes": { "itemtypes": {
"contract": "", "contract": "",
@@ -1234,6 +1244,7 @@
"areyousure": "", "areyousure": "",
"barcode": "código de barras", "barcode": "código de barras",
"cancel": "", "cancel": "",
"changelog": "",
"clear": "", "clear": "",
"confirmpassword": "", "confirmpassword": "",
"created_at": "", "created_at": "",
@@ -1243,6 +1254,7 @@
"errors": "", "errors": "",
"excel": "", "excel": "",
"exceptiontitle": "", "exceptiontitle": "",
"feature-request": "",
"friday": "", "friday": "",
"globalsearch": "", "globalsearch": "",
"help": "", "help": "",
@@ -1321,6 +1333,7 @@
"notfoundtitle": "", "notfoundtitle": "",
"partnernotrunning": "", "partnernotrunning": "",
"rbacunauth": "", "rbacunauth": "",
"submit-for-testing": "",
"unsavedchanges": "Usted tiene cambios no guardados.", "unsavedchanges": "Usted tiene cambios no guardados.",
"unsavedchangespopup": "" "unsavedchangespopup": ""
}, },
@@ -1629,10 +1642,13 @@
"voiding": "" "voiding": ""
}, },
"fields": { "fields": {
"estimate_sent_approval": "",
"estimate_approved": "",
"active_tasks": "", "active_tasks": "",
"actual_completion": "Realización real", "actual_completion": "Realización real",
"actual_delivery": "Entrega real", "actual_delivery": "Entrega real",
"actual_in": "Real en", "actual_in": "Real en",
"acv_amount": "",
"adjustment_bottom_line": "Ajustes", "adjustment_bottom_line": "Ajustes",
"adjustmenthours": "", "adjustmenthours": "",
"alt_transport": "", "alt_transport": "",
@@ -1758,9 +1774,10 @@
"est_ct_ln": "Apellido del tasador", "est_ct_ln": "Apellido del tasador",
"est_ea": "Correo electrónico del tasador", "est_ea": "Correo electrónico del tasador",
"est_ph1": "Número de teléfono del tasador", "est_ph1": "Número de teléfono del tasador",
"flat_rate_ats": "",
"federal_tax_payable": "Impuesto federal por pagar", "federal_tax_payable": "Impuesto federal por pagar",
"federal_tax_rate": "", "federal_tax_rate": "",
"flat_rate_ats": "",
"hit_and_run": "",
"ins_addr1": "Dirección de Insurance Co.", "ins_addr1": "Dirección de Insurance Co.",
"ins_city": "Ciudad de seguros", "ins_city": "Ciudad de seguros",
"ins_co_id": "ID de la compañía de seguros", "ins_co_id": "ID de la compañía de seguros",
@@ -1940,6 +1957,8 @@
"scheddates": "" "scheddates": ""
}, },
"labels": { "labels": {
"sent": "",
"approved": "",
"accountsreceivable": "", "accountsreceivable": "",
"act_price_ppc": "", "act_price_ppc": "",
"actual_completion_inferred": "", "actual_completion_inferred": "",
@@ -2314,6 +2333,7 @@
"duplicate": "", "duplicate": "",
"duplicatenolines": "", "duplicatenolines": "",
"newcccontract": "", "newcccontract": "",
"submit-for-testing": "",
"void": "" "void": ""
}, },
"jobsdetail": { "jobsdetail": {
@@ -2420,6 +2440,65 @@
"updated": "Nota actualizada con éxito." "updated": "Nota actualizada con éxito."
} }
}, },
"notifications": {
"actions": {
"remove": ""
},
"aria": {
"toggle": ""
},
"channels": {
"app": "",
"email": "",
"fcm": ""
},
"labels": {
"auto-add-on": "",
"auto-add-off": "",
"auto-add-success": "",
"auto-add-failure": "",
"auto-add-description": "",
"add-watchers": "",
"add-watchers-team": "",
"employee-search": "",
"mark-all-read": "",
"new-notification-title": "",
"no-watchers": "",
"notification-center": "",
"notification-popup-title": "",
"notification-settings-failure": "",
"notification-settings-success": "",
"notificationscenarios": "",
"ro-number": "",
"save": "",
"scenario": "",
"show-unread-only": "",
"teams-search": "",
"unwatch": "",
"watch": "",
"watching-issue": ""
},
"scenarios": {
"alternate-transport-changed": "",
"bill-posted": "",
"critical-parts-status-changed": "",
"intake-delivery-checklist-completed": "",
"job-added-to-production": "",
"job-assigned-to-me": "",
"job-status-change": "",
"new-media-added-reassigned": "",
"new-note-added": "",
"new-time-ticket-posted": "",
"part-marked-back-ordered": "",
"payment-collected-completed": "",
"schedule-dates-changed": "",
"supplement-imported": "",
"tasks-updated-created": ""
},
"tooltips": {
"job-watchers": ""
}
},
"owner": { "owner": {
"labels": { "labels": {
"noownerinfo": "" "noownerinfo": ""
@@ -3416,6 +3495,7 @@
"dashboard": "", "dashboard": "",
"dms": "", "dms": "",
"export-logs": "", "export-logs": "",
"feature-request": "",
"inventory": "", "inventory": "",
"jobs": "", "jobs": "",
"jobs-active": "", "jobs-active": "",
@@ -3460,6 +3540,7 @@
"dashboard": "", "dashboard": "",
"dms": "", "dms": "",
"export-logs": "", "export-logs": "",
"feature-request": "",
"imexonline": "", "imexonline": "",
"inventory": "", "inventory": "",
"jobs": "Todos los trabajos | {{app}}", "jobs": "Todos los trabajos | {{app}}",
@@ -3677,10 +3758,10 @@
"users": { "users": {
"errors": { "errors": {
"signinerror": { "signinerror": {
"auth/invalid-email": "",
"auth/user-disabled": "", "auth/user-disabled": "",
"auth/user-not-found": "", "auth/user-not-found": "",
"auth/wrong-password": "", "auth/wrong-password": ""
"auth/invalid-email": ""
} }
} }
}, },
@@ -3780,60 +3861,6 @@
"validation": { "validation": {
"unique_vendor_name": "" "unique_vendor_name": ""
} }
},
"notifications": {
"labels": {
"notification-center": "",
"scenario": "",
"notificationscenarios": "",
"save": "",
"watching-issue": "",
"add-watchers": "",
"employee-search": "",
"teams-search": "",
"add-watchers-team": "",
"new-notification-title": "",
"show-unread-only": "",
"mark-all-read": "",
"notification-popup-title": "",
"ro-number": "",
"no-watchers": "",
"notification-settings-success": "",
"notification-settings-failure": "",
"watch": "",
"unwatch": ""
},
"actions": {
"remove": ""
},
"aria": {
"toggle": ""
},
"tooltips": {
"job-watchers": ""
},
"scenarios": {
"job-assigned-to-me": "",
"bill-posted": "",
"critical-parts-status-changed": "",
"part-marked-back-ordered": "",
"new-note-added": "",
"supplement-imported": "",
"schedule-dates-changed": "",
"tasks-updated-created": "",
"new-media-added-reassigned": "",
"new-time-ticket-posted": "",
"intake-delivery-checklist-completed": "",
"job-added-to-production": "",
"job-status-change": "",
"payment-collected-completed": "",
"alternate-transport-changed": ""
},
"channels": {
"app": "",
"email": "",
"fcm": ""
}
} }
} }
} }

View File

@@ -335,7 +335,6 @@
"intellipay_config": { "intellipay_config": {
"cash_discount_percentage": "", "cash_discount_percentage": "",
"enable_cash_discount": "", "enable_cash_discount": "",
"payment_type": "",
"payment_map": { "payment_map": {
"amex": "American Express", "amex": "American Express",
"disc": "Discover", "disc": "Discover",
@@ -344,7 +343,8 @@
"jcb": "JCB", "jcb": "JCB",
"mast": "MasterCard", "mast": "MasterCard",
"visa": "Visa" "visa": "Visa"
} },
"payment_type": ""
}, },
"invoice_federal_tax_rate": "", "invoice_federal_tax_rate": "",
"invoice_local_tax_rate": "", "invoice_local_tax_rate": "",
@@ -601,7 +601,8 @@
"templates": "" "templates": ""
}, },
"ss_configuration": { "ss_configuration": {
"dailyhrslimit": "" "dailyhrslimit": "",
"nobusinessdays": ""
}, },
"ssbuckets": { "ssbuckets": {
"color": "", "color": "",
@@ -647,7 +648,12 @@
"use_paint_scale_data": "", "use_paint_scale_data": "",
"uselocalmediaserver": "", "uselocalmediaserver": "",
"website": "", "website": "",
"zip_post": "" "zip_post": "",
"notifications": {
"description": "",
"placeholder": "",
"invalid_followers": ""
}
}, },
"labels": { "labels": {
"2tiername": "", "2tiername": "",
@@ -727,7 +733,10 @@
"ssbuckets": "", "ssbuckets": "",
"systemsettings": "", "systemsettings": "",
"task-presets": "", "task-presets": "",
"workingdays": "" "workingdays": "",
"notifications": {
"followers": ""
}
}, },
"operations": { "operations": {
"contains": "", "contains": "",
@@ -1220,7 +1229,8 @@
"errors": { "errors": {
"fcm": "", "fcm": "",
"notfound": "", "notfound": "",
"sizelimit": "" "sizelimit": "",
"submit-for-testing": ""
}, },
"itemtypes": { "itemtypes": {
"contract": "", "contract": "",
@@ -1234,6 +1244,7 @@
"areyousure": "", "areyousure": "",
"barcode": "code à barre", "barcode": "code à barre",
"cancel": "", "cancel": "",
"changelog": "",
"clear": "", "clear": "",
"confirmpassword": "", "confirmpassword": "",
"created_at": "", "created_at": "",
@@ -1243,6 +1254,7 @@
"errors": "", "errors": "",
"excel": "", "excel": "",
"exceptiontitle": "", "exceptiontitle": "",
"feature-request": "",
"friday": "", "friday": "",
"globalsearch": "", "globalsearch": "",
"help": "", "help": "",
@@ -1321,6 +1333,7 @@
"notfoundtitle": "", "notfoundtitle": "",
"partnernotrunning": "", "partnernotrunning": "",
"rbacunauth": "", "rbacunauth": "",
"submit-for-testing": "",
"unsavedchanges": "Vous avez des changements non enregistrés.", "unsavedchanges": "Vous avez des changements non enregistrés.",
"unsavedchangespopup": "" "unsavedchangespopup": ""
}, },
@@ -1629,10 +1642,13 @@
"voiding": "" "voiding": ""
}, },
"fields": { "fields": {
"estimate_sent_approval": "",
"estimate_approved": "",
"active_tasks": "", "active_tasks": "",
"actual_completion": "Achèvement réel", "actual_completion": "Achèvement réel",
"actual_delivery": "Livraison réelle", "actual_delivery": "Livraison réelle",
"actual_in": "En réel", "actual_in": "En réel",
"acv_amount": "",
"adjustment_bottom_line": "Ajustements", "adjustment_bottom_line": "Ajustements",
"adjustmenthours": "", "adjustmenthours": "",
"alt_transport": "", "alt_transport": "",
@@ -1758,9 +1774,10 @@
"est_ct_ln": "Nom de l'évaluateur", "est_ct_ln": "Nom de l'évaluateur",
"est_ea": "Courriel de l'évaluateur", "est_ea": "Courriel de l'évaluateur",
"est_ph1": "Numéro de téléphone de l'évaluateur", "est_ph1": "Numéro de téléphone de l'évaluateur",
"flat_rate_ats": "",
"federal_tax_payable": "Impôt fédéral à payer", "federal_tax_payable": "Impôt fédéral à payer",
"federal_tax_rate": "", "federal_tax_rate": "",
"flat_rate_ats": "",
"hit_and_run": "",
"ins_addr1": "Adresse Insurance Co.", "ins_addr1": "Adresse Insurance Co.",
"ins_city": "Insurance City", "ins_city": "Insurance City",
"ins_co_id": "ID de la compagnie d'assurance", "ins_co_id": "ID de la compagnie d'assurance",
@@ -1940,6 +1957,8 @@
"scheddates": "" "scheddates": ""
}, },
"labels": { "labels": {
"sent": "",
"approved": "",
"accountsreceivable": "", "accountsreceivable": "",
"act_price_ppc": "", "act_price_ppc": "",
"actual_completion_inferred": "", "actual_completion_inferred": "",
@@ -2314,6 +2333,7 @@
"duplicate": "", "duplicate": "",
"duplicatenolines": "", "duplicatenolines": "",
"newcccontract": "", "newcccontract": "",
"submit-for-testing": "",
"void": "" "void": ""
}, },
"jobsdetail": { "jobsdetail": {
@@ -2420,6 +2440,65 @@
"updated": "Remarque mise à jour avec succès." "updated": "Remarque mise à jour avec succès."
} }
}, },
"notifications": {
"actions": {
"remove": ""
},
"aria": {
"toggle": ""
},
"channels": {
"app": "",
"email": "",
"fcm": ""
},
"labels": {
"auto-add-on": "",
"auto-add-off": "",
"auto-add-success": "",
"auto-add-failure": "",
"auto-add-description": "",
"add-watchers": "",
"add-watchers-team": "",
"employee-search": "",
"mark-all-read": "",
"new-notification-title": "",
"no-watchers": "",
"notification-center": "",
"notification-popup-title": "",
"notification-settings-failure": "",
"notification-settings-success": "",
"notificationscenarios": "",
"ro-number": "",
"save": "",
"scenario": "",
"show-unread-only": "",
"teams-search": "",
"unwatch": "",
"watch": "",
"watching-issue": ""
},
"scenarios": {
"alternate-transport-changed": "",
"bill-posted": "",
"critical-parts-status-changed": "",
"intake-delivery-checklist-completed": "",
"job-added-to-production": "",
"job-assigned-to-me": "",
"job-status-change": "",
"new-media-added-reassigned": "",
"new-note-added": "",
"new-time-ticket-posted": "",
"part-marked-back-ordered": "",
"payment-collected-completed": "",
"schedule-dates-changed": "",
"supplement-imported": "",
"tasks-updated-created": ""
},
"tooltips": {
"job-watchers": ""
}
},
"owner": { "owner": {
"labels": { "labels": {
"noownerinfo": "" "noownerinfo": ""
@@ -3416,6 +3495,7 @@
"dashboard": "", "dashboard": "",
"dms": "", "dms": "",
"export-logs": "", "export-logs": "",
"feature-request": "",
"inventory": "", "inventory": "",
"jobs": "", "jobs": "",
"jobs-active": "", "jobs-active": "",
@@ -3460,6 +3540,7 @@
"dashboard": "", "dashboard": "",
"dms": "", "dms": "",
"export-logs": "", "export-logs": "",
"feature-request": "",
"imexonline": "", "imexonline": "",
"inventory": "", "inventory": "",
"jobs": "Tous les emplois | {{app}}", "jobs": "Tous les emplois | {{app}}",
@@ -3677,10 +3758,10 @@
"users": { "users": {
"errors": { "errors": {
"signinerror": { "signinerror": {
"auth/invalid-email": "",
"auth/user-disabled": "", "auth/user-disabled": "",
"auth/user-not-found": "", "auth/user-not-found": "",
"auth/wrong-password": "", "auth/wrong-password": ""
"auth/invalid-email": ""
} }
} }
}, },
@@ -3780,60 +3861,6 @@
"validation": { "validation": {
"unique_vendor_name": "" "unique_vendor_name": ""
} }
},
"notifications": {
"labels": {
"notification-center": "",
"scenario": "",
"notificationscenarios": "",
"save": "",
"watching-issue": "",
"add-watchers": "",
"employee-search": "",
"teams-search": "",
"add-watchers-team": "",
"new-notification-title": "",
"show-unread-only": "",
"mark-all-read": "",
"notification-popup-title": "",
"ro-number": "",
"no-watchers": "",
"notification-settings-success": "",
"notification-settings-failure": "",
"watch": "",
"unwatch": ""
},
"actions": {
"remove": ""
},
"aria": {
"toggle": ""
},
"tooltips": {
"job-watchers": ""
},
"scenarios": {
"job-assigned-to-me": "",
"bill-posted": "",
"critical-parts-status-changed": "",
"part-marked-back-ordered": "",
"new-note-added": "",
"supplement-imported": "",
"schedule-dates-changed": "",
"tasks-updated-created": "",
"new-media-added-reassigned": "",
"new-time-ticket-posted": "",
"intake-delivery-checklist-completed": "",
"job-added-to-production": "",
"job-status-change": "",
"payment-collected-completed": "",
"alternate-transport-changed": ""
},
"channels": {
"app": "",
"email": "",
"fcm": ""
}
} }
} }
} }

View File

@@ -15,8 +15,8 @@ const AuditTrailMapping = {
jobchecklist: (type, inproduction, status) => jobchecklist: (type, inproduction, status) =>
i18n.t("audit_trail.messages.jobchecklist", { type, inproduction, status }), i18n.t("audit_trail.messages.jobchecklist", { type, inproduction, status }),
jobconverted: (ro_number) => i18n.t("audit_trail.messages.jobconverted", { ro_number }), jobconverted: (ro_number) => i18n.t("audit_trail.messages.jobconverted", { ro_number }),
jobintake: (status, email, scheduled_completion) => jobintake: (status, scheduled_completion) =>
i18n.t("audit_trail.messages.jobintake", { status, email, scheduled_completion }), i18n.t("audit_trail.messages.jobintake", { status, scheduled_completion }),
jobdelivery: (status, email, actual_completion) => jobdelivery: (status, email, actual_completion) =>
i18n.t("audit_trail.messages.jobdelivery", { status, email, actual_completion }), i18n.t("audit_trail.messages.jobdelivery", { status, email, actual_completion }),
jobexported: () => i18n.t("audit_trail.messages.jobexported"), jobexported: () => i18n.t("audit_trail.messages.jobexported"),

View File

@@ -14,10 +14,7 @@ const onServiceWorkerUpdate = (registration) => {
<Button <Button
onClick={async () => { onClick={async () => {
window.open( window.open(
InstanceRenderManager({ `https://shopmanagement.canny.io/changelog`,
imex: "https://imex-online.noticeable.news/",
rome: "https://rome-online.noticeable.news/"
}),
"_blank" "_blank"
); );
}} }}

View File

@@ -15,7 +15,7 @@ const currentDatePST = new Date()
.reverse() .reverse()
.join("-"); .join("-");
const sentryRelease = const sentryRelease =
`${import.meta.env.VITE_APP_IS_TEST ? "test" : "production"}-${currentDatePST}-${process.env.VITE_GIT_COMMIT_HASH}`.trim(); `${import.meta.env.VITE_APP_IS_TEST ? "test" : "production"}-${currentDatePST}`.trim();
if (!import.meta.env.DEV) { if (!import.meta.env.DEV) {
Sentry.init({ Sentry.init({

View File

@@ -117,6 +117,7 @@ services:
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1 aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1 aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-job-totals --create-bucket-configuration LocationConstraint=ca-central-1
" "
# Node App: The Main IMEX API # Node App: The Main IMEX API
node-app: node-app:

View File

@@ -0,0 +1,77 @@
const fs = require("fs");
const path = require("path");
const logger = require("./server/utils/logger"); // Assuming same logger utility
const s3Client = require("./server/utils/s3"); // Using the S3 client utilities with LocalStack support
// Set bucket name for development with LocalStack
const S3_BUCKET_NAME = "imex-job-totals";
// Set fixtures directory path
const FIXTURES_DIR = path.join(__dirname, "server", "job", "test", "fixtures", "job-totals");
const ensureFixturesDirectory = () => {
if (!fs.existsSync(FIXTURES_DIR)) {
fs.mkdirSync(FIXTURES_DIR, { recursive: true });
logger.log(`Created fixtures directory: ${FIXTURES_DIR}`, "info");
}
};
const downloadJsonFiles = async (userInfo = { email: "system" }) => {
logger.log(`Starting download of JSON files from bucket: ${S3_BUCKET_NAME}`, "debug", userInfo.email);
try {
ensureFixturesDirectory();
const contents = await s3Client.listFilesInS3Bucket(S3_BUCKET_NAME);
if (!contents.length) {
logger.log("No files found in bucket", "info", userInfo.email);
return;
}
logger.log(`Found ${contents.length} files in bucket`, "info", userInfo.email);
for (const item of contents) {
if (!item.Key.endsWith(".json")) {
logger.log(`Skipping non-JSON file: ${item.Key}`, "debug", userInfo.email);
continue;
}
logger.log(`Downloading: ${item.Key}`, "debug", userInfo.email);
const fileData = await s3Client.downloadFileFromS3({
bucketName: S3_BUCKET_NAME,
key: item.Key
});
const fileContent = await fileData.transformToString();
const fileName = path.basename(item.Key);
const filePath = path.join(FIXTURES_DIR, fileName);
fs.writeFileSync(filePath, fileContent);
logger.log(`Saved: ${filePath}`, "info", userInfo.email);
}
logger.log("Download completed successfully", "info", userInfo.email);
} catch (error) {
logger.log("Failed to download JSON files", "error", userInfo.email, null, {
error: error?.message,
stack: error?.stack
});
throw error; // Re-throw to trigger process exit with error code
}
};
// Run the download if script is executed directly
if (require.main === module) {
(async () => {
try {
await downloadJsonFiles();
console.log("Script completed successfully");
process.exit(0); // Explicitly exit with success code
} catch (error) {
console.error("Fatal error downloading files:", error);
process.exit(1); // Explicitly exit with error code
}
})();
}
module.exports = downloadJsonFiles;

View File

@@ -31,6 +31,15 @@
headers: headers:
- name: x-imex-auth - name: x-imex-auth
value_from_env: DATAPUMP_AUTH value_from_env: DATAPUMP_AUTH
- name: Podium Data Pump
webhook: '{{HASURA_API_URL}}/data/podium'
schedule: 15 5 * * *
include_in_metadata: true
payload: {}
headers:
- name: x-imex-auth
value_from_env: DATAPUMP_AUTH
comment: ""
- name: Rome Usage Report - name: Rome Usage Report
webhook: '{{HASURA_API_URL}}/data/usagereport' webhook: '{{HASURA_API_URL}}/data/usagereport'
schedule: 0 12 * * 5 schedule: 0 12 * * 5

View File

@@ -216,6 +216,7 @@
- id - id
- kanban_settings - kanban_settings
- notification_settings - notification_settings
- notifications_autoadd
- qbo_realmId - qbo_realmId
- shopid - shopid
- useremail - useremail
@@ -232,6 +233,7 @@
- default_prod_list_view - default_prod_list_view
- kanban_settings - kanban_settings
- notification_settings - notification_settings
- notifications_autoadd
- qbo_realmId - qbo_realmId
filter: filter:
user: user:
@@ -965,6 +967,7 @@
- insurance_vendor_id - insurance_vendor_id
- intakechecklist - intakechecklist
- intellipay_config - intellipay_config
- intellipay_merchant_id
- jc_hourly_rates - jc_hourly_rates
- jobsizelimit - jobsizelimit
- last_name_first - last_name_first
@@ -1001,9 +1004,11 @@
- md_tasks_presets - md_tasks_presets
- md_to_emails - md_to_emails
- messagingservicesid - messagingservicesid
- notification_followers
- pbs_configuration - pbs_configuration
- pbs_serialnumber - pbs_serialnumber
- phone - phone
- podiumid
- prodtargethrs - prodtargethrs
- production_config - production_config
- region_config - region_config
@@ -1023,6 +1028,7 @@
- template_header - template_header
- textid - textid
- timezone - timezone
- tours_enabled
- tt_allow_post_to_invoiced - tt_allow_post_to_invoiced
- tt_enforce_hours_for_tech_console - tt_enforce_hours_for_tech_console
- updated_at - updated_at
@@ -1101,6 +1107,7 @@
- md_ro_statuses - md_ro_statuses
- md_tasks_presets - md_tasks_presets
- md_to_emails - md_to_emails
- notification_followers
- pbs_configuration - pbs_configuration
- phone - phone
- prodtargethrs - prodtargethrs
@@ -3592,6 +3599,7 @@
- actual_completion - actual_completion
- actual_delivery - actual_delivery
- actual_in - actual_in
- acv_amount
- adj_g_disc - adj_g_disc
- adj_strdis - adj_strdis
- adj_towdis - adj_towdis
@@ -3694,9 +3702,12 @@
- est_ph1 - est_ph1
- est_st - est_st
- est_zip - est_zip
- estimate_approved
- estimate_sent_approval
- federal_tax_rate - federal_tax_rate
- flat_rate_ats - flat_rate_ats
- g_bett_amt - g_bett_amt
- hit_and_run
- id - id
- inproduction - inproduction
- ins_addr1 - ins_addr1
@@ -3863,6 +3874,7 @@
- actual_completion - actual_completion
- actual_delivery - actual_delivery
- actual_in - actual_in
- acv_amount
- adj_g_disc - adj_g_disc
- adj_strdis - adj_strdis
- adj_towdis - adj_towdis
@@ -3966,9 +3978,12 @@
- est_ph1 - est_ph1
- est_st - est_st
- est_zip - est_zip
- estimate_approved
- estimate_sent_approval
- federal_tax_rate - federal_tax_rate
- flat_rate_ats - flat_rate_ats
- g_bett_amt - g_bett_amt
- hit_and_run
- id - id
- inproduction - inproduction
- ins_addr1 - ins_addr1
@@ -4147,6 +4162,7 @@
- actual_completion - actual_completion
- actual_delivery - actual_delivery
- actual_in - actual_in
- acv_amount
- adj_g_disc - adj_g_disc
- adj_strdis - adj_strdis
- adj_towdis - adj_towdis
@@ -4250,9 +4266,12 @@
- est_ph1 - est_ph1
- est_st - est_st
- est_zip - est_zip
- estimate_approved
- estimate_sent_approval
- federal_tax_rate - federal_tax_rate
- flat_rate_ats - flat_rate_ats
- g_bett_amt - g_bett_amt
- hit_and_run
- id - id
- inproduction - inproduction
- ins_addr1 - ins_addr1
@@ -4579,12 +4598,34 @@
request_transform: request_transform:
body: body:
action: transform action: transform
template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body?.event?.session_variables?.x-hasura-user-id ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\r\n }, \r\n \"op\": \"UPDATE\",\r\n \"data\": {\r\n \"old\": {\r\n \"id\": {{$body.event.data.old.id}},\r\n \"ro_number\": {{$body.event.data.old.ro_number}},\r\n \"queued_for_parts\": {{$body.event.data.old.queued_for_parts}},\r\n \"employee_prep\": {{$body.event.data.old.employee_prep}},\r\n \"clm_total\": {{$body.event.data.old.clm_total}},\r\n \"towin\": {{$body.event.data.old.towin}},\r\n \"employee_body\": {{$body.event.data.old.employee_body}},\r\n \"converted\": {{$body.event.data.old.converted}},\r\n \"scheduled_in\": {{$body.event.data.old.scheduled_in}},\r\n \"scheduled_completion\": {{$body.event.data.old.scheduled_completion}},\r\n \"scheduled_delivery\": {{$body.event.data.old.scheduled_delivery}},\r\n \"actual_delivery\": {{$body.event.data.old.actual_delivery}},\r\n \"actual_completion\": {{$body.event.data.old.actual_completion}},\r\n \"alt_transport\": {{$body.event.data.old.alt_transport}},\r\n \"date_exported\": {{$body.event.data.old.date_exported}},\r\n \"status\": {{$body.event.data.old.status}},\r\n \"employee_csr\": {{$body.event.data.old.employee_csr}},\r\n \"actual_in\": {{$body.event.data.old.actual_in}},\r\n \"deliverchecklist\": {{$body.event.data.old.deliverchecklist}},\r\n \"comment\": {{$body.event.data.old.comment}},\r\n \"employee_refinish\": {{$body.event.data.old.employee_refinish}},\r\n \"inproduction\": {{$body.event.data.old.inproduction}},\r\n \"production_vars\": {{$body.event.data.old.production_vars}},\r\n \"intakechecklist\": {{$body.event.data.old.intakechecklist}},\r\n \"cieca_ttl\": {{$body.event.data.old.cieca_ttl}},\r\n \"date_invoiced\": {{$body.event.data.old.date_invoiced}}\r\n },\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"ro_number\": {{$body.event.data.old.ro_number}},\r\n \"queued_for_parts\": {{$body.event.data.new.queued_for_parts}},\r\n \"employee_prep\": {{$body.event.data.new.employee_prep}},\r\n \"clm_total\": {{$body.event.data.new.clm_total}},\r\n \"towin\": {{$body.event.data.new.towin}},\r\n \"employee_body\": {{$body.event.data.new.employee_body}},\r\n \"converted\": {{$body.event.data.new.converted}},\r\n \"scheduled_in\": {{$body.event.data.new.scheduled_in}},\r\n \"scheduled_completion\": {{$body.event.data.new.scheduled_completion}},\r\n \"scheduled_delivery\": {{$body.event.data.new.scheduled_delivery}},\r\n \"actual_delivery\": {{$body.event.data.new.actual_delivery}},\r\n \"actual_completion\": {{$body.event.data.new.actual_completion}},\r\n \"alt_transport\": {{$body.event.data.new.alt_transport}},\r\n \"date_exported\": {{$body.event.data.new.date_exported}},\r\n \"status\": {{$body.event.data.new.status}},\r\n \"employee_csr\": {{$body.event.data.new.employee_csr}},\r\n \"actual_in\": {{$body.event.data.new.actual_in}},\r\n \"deliverchecklist\": {{$body.event.data.new.deliverchecklist}},\r\n \"comment\": {{$body.event.data.new.comment}},\r\n \"employee_refinish\": {{$body.event.data.new.employee_refinish}},\r\n \"inproduction\": {{$body.event.data.new.inproduction}},\r\n \"production_vars\": {{$body.event.data.new.production_vars}},\r\n \"intakechecklist\": {{$body.event.data.new.intakechecklist}},\r\n \"cieca_ttl\": {{$body.event.data.new.cieca_ttl}},\r\n \"date_invoiced\": {{$body.event.data.new.date_invoiced}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_jobs\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"jobs\"\r\n }\r\n}\r\n" template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body?.event?.session_variables?.x-hasura-user-id ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\r\n }, \r\n \"op\": {{$body.event.op}},\r\n \"data\": {\r\n \"old\": {\r\n \"id\": {{$body.event.data.old.id}},\r\n \"ro_number\": {{$body.event.data.old.ro_number}},\r\n \"queued_for_parts\": {{$body.event.data.old.queued_for_parts}},\r\n \"employee_prep\": {{$body.event.data.old.employee_prep}},\r\n \"clm_total\": {{$body.event.data.old.clm_total}},\r\n \"towin\": {{$body.event.data.old.towin}},\r\n \"employee_body\": {{$body.event.data.old.employee_body}},\r\n \"converted\": {{$body.event.data.old.converted}},\r\n \"scheduled_in\": {{$body.event.data.old.scheduled_in}},\r\n \"scheduled_completion\": {{$body.event.data.old.scheduled_completion}},\r\n \"scheduled_delivery\": {{$body.event.data.old.scheduled_delivery}},\r\n \"actual_delivery\": {{$body.event.data.old.actual_delivery}},\r\n \"actual_completion\": {{$body.event.data.old.actual_completion}},\r\n \"alt_transport\": {{$body.event.data.old.alt_transport}},\r\n \"date_exported\": {{$body.event.data.old.date_exported}},\r\n \"status\": {{$body.event.data.old.status}},\r\n \"employee_csr\": {{$body.event.data.old.employee_csr}},\r\n \"actual_in\": {{$body.event.data.old.actual_in}},\r\n \"deliverchecklist\": {{$body.event.data.old.deliverchecklist}},\r\n \"comment\": {{$body.event.data.old.comment}},\r\n \"employee_refinish\": {{$body.event.data.old.employee_refinish}},\r\n \"inproduction\": {{$body.event.data.old.inproduction}},\r\n \"production_vars\": {{$body.event.data.old.production_vars}},\r\n \"intakechecklist\": {{$body.event.data.old.intakechecklist}},\r\n \"cieca_ttl\": {{$body.event.data.old.cieca_ttl}},\r\n \"date_invoiced\": {{$body.event.data.old.date_invoiced}}\r\n },\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"ro_number\": {{$body.event.data.old.ro_number}},\r\n \"queued_for_parts\": {{$body.event.data.new.queued_for_parts}},\r\n \"employee_prep\": {{$body.event.data.new.employee_prep}},\r\n \"clm_total\": {{$body.event.data.new.clm_total}},\r\n \"towin\": {{$body.event.data.new.towin}},\r\n \"employee_body\": {{$body.event.data.new.employee_body}},\r\n \"converted\": {{$body.event.data.new.converted}},\r\n \"scheduled_in\": {{$body.event.data.new.scheduled_in}},\r\n \"scheduled_completion\": {{$body.event.data.new.scheduled_completion}},\r\n \"scheduled_delivery\": {{$body.event.data.new.scheduled_delivery}},\r\n \"actual_delivery\": {{$body.event.data.new.actual_delivery}},\r\n \"actual_completion\": {{$body.event.data.new.actual_completion}},\r\n \"alt_transport\": {{$body.event.data.new.alt_transport}},\r\n \"date_exported\": {{$body.event.data.new.date_exported}},\r\n \"status\": {{$body.event.data.new.status}},\r\n \"employee_csr\": {{$body.event.data.new.employee_csr}},\r\n \"actual_in\": {{$body.event.data.new.actual_in}},\r\n \"deliverchecklist\": {{$body.event.data.new.deliverchecklist}},\r\n \"comment\": {{$body.event.data.new.comment}},\r\n \"employee_refinish\": {{$body.event.data.new.employee_refinish}},\r\n \"inproduction\": {{$body.event.data.new.inproduction}},\r\n \"production_vars\": {{$body.event.data.new.production_vars}},\r\n \"intakechecklist\": {{$body.event.data.new.intakechecklist}},\r\n \"cieca_ttl\": {{$body.event.data.new.cieca_ttl}},\r\n \"date_invoiced\": {{$body.event.data.new.date_invoiced}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_jobs\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"jobs\"\r\n }\r\n}\r\n"
method: POST method: POST
query_params: {} query_params: {}
template_engine: Kriti template_engine: Kriti
url: '{{$base_url}}/notifications/events/handleJobsChange' url: '{{$base_url}}/notifications/events/handleJobsChange'
version: 2 version: 2
- name: notifications_jobs_autoadd
definition:
enable_manual: false
insert:
columns: '*'
retry_conf:
interval_sec: 10
num_retries: 0
timeout_sec: 60
webhook_from_env: HASURA_API_URL
headers:
- name: event-secret
value_from_env: EVENT_SECRET
request_transform:
body:
action: transform
template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body?.event?.session_variables?.x-hasura-user-id ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\r\n }, \r\n \"op\": {{$body.event.op}},\r\n \"data\": {\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"shopid\": {{$body.event.data.new?.shopid}},\r\n \"ro_number\": {{$body.event.data.new?.ro_number}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_jobs_autoadd\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"jobs\"\r\n }\r\n}\r\n"
method: POST
query_params: {}
template_engine: Kriti
url: '{{$base_url}}/notifications/events/handleAutoAdd'
version: 2
- name: os_jobs - name: os_jobs
definition: definition:
delete: delete:

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 "tours_enabled" boolean
-- not null default 'true';

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" add column "tours_enabled" boolean
not null default 'true';

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 "podiumid" text
-- null;

View File

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

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"."jobs" add column "hit_and_run" boolean
-- null default 'false';

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "hit_and_run" 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"."jobs" add column "acv_amount" numeric
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "acv_amount" numeric
null;

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 "notification_followers" json
-- null default json_build_object();

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" add column "notification_followers" json
null default json_build_object();

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"."associations" add column "notifications_autoadd" boolean
-- null default 'false';

View File

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

View File

@@ -0,0 +1 @@
alter table "public"."bodyshops" alter column "notification_followers" set default json_build_object();

View File

@@ -0,0 +1 @@
alter table "public"."bodyshops" alter column "notification_followers" set default json_build_array();

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"."jobs" add column "estimate_sent_approval" timestamptz
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "estimate_sent_approval" timestamptz
null;

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"."jobs" add column "estimate_approved" timestamptz
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "estimate_approved" timestamptz
null;

4926
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"version": "0.2.0", "version": "0.2.0",
"license": "UNLICENSED", "license": "UNLICENSED",
"engines": { "engines": {
"node": ">=18.0.0", "node": ">=22.13.0",
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"scripts": { "scripts": {
@@ -12,63 +12,60 @@
"start": "node server.js", "start": "node server.js",
"makeitpretty": "prettier --write \"**/*.{css,js,json,jsx,scss}\"", "makeitpretty": "prettier --write \"**/*.{css,js,json,jsx,scss}\"",
"test:unit": "vitest run", "test:unit": "vitest run",
"test:watch": "vitest" "test:watch": "vitest",
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.777.0", "@aws-sdk/client-cloudwatch-logs": "^3.804.0",
"@aws-sdk/client-elasticache": "^3.777.0", "@aws-sdk/client-elasticache": "^3.804.0",
"@aws-sdk/client-s3": "^3.779.0", "@aws-sdk/client-s3": "^3.804.0",
"@aws-sdk/client-secrets-manager": "^3.777.0", "@aws-sdk/client-secrets-manager": "^3.804.0",
"@aws-sdk/client-ses": "^3.777.0", "@aws-sdk/client-ses": "^3.804.0",
"@aws-sdk/credential-provider-node": "^3.777.0", "@aws-sdk/credential-provider-node": "^3.804.0",
"@aws-sdk/lib-storage": "^3.779.0", "@aws-sdk/lib-storage": "^3.804.0",
"@aws-sdk/s3-request-presigner": "^3.779.0", "@aws-sdk/s3-request-presigner": "^3.804.0",
"@opensearch-project/opensearch": "^2.13.0", "@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1", "@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"aws4": "^1.13.2", "aws4": "^1.13.2",
"axios": "^1.8.4", "axios": "^1.8.4",
"bee-queue": "^1.7.1",
"better-queue": "^3.8.12", "better-queue": "^3.8.12",
"bluebird": "^3.7.2", "bullmq": "^5.52.2",
"bullmq": "^5.45.2",
"chart.js": "^4.4.8", "chart.js": "^4.4.8",
"cloudinary": "^2.6.0", "cloudinary": "^2.6.1",
"compression": "^1.8.0", "compression": "^1.8.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "2.8.5", "cors": "2.8.5",
"crisp-status-reporter": "^1.2.2", "crisp-status-reporter": "^1.2.2",
"csrf": "^3.1.0", "dd-trace": "^5.51.0",
"dd-trace": "^5.45.0",
"dinero.js": "^1.9.1", "dinero.js": "^1.9.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.1", "express": "^4.21.1",
"firebase-admin": "^13.2.0", "firebase-admin": "^13.2.0",
"graphql": "^16.10.0", "graphql": "^16.11.0",
"graphql-request": "^6.1.0", "graphql-request": "^6.1.0",
"inline-css": "^4.0.3",
"intuit-oauth": "^4.2.0", "intuit-oauth": "^4.2.0",
"ioredis": "^5.6.0", "ioredis": "^5.6.0",
"json-2-csv": "^5.5.9", "json-2-csv": "^5.5.9",
"jsonwebtoken": "^9.0.2",
"juice": "^11.0.1", "juice": "^11.0.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.30.1", "moment": "^2.30.1",
"moment-timezone": "^0.5.48", "moment-timezone": "^0.5.48",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-mailjet": "^6.0.8",
"node-persist": "^4.0.4", "node-persist": "^4.0.4",
"nodemailer": "^6.10.0", "nodemailer": "^6.10.0",
"phone": "^3.1.58", "phone": "^3.1.58",
"query-string": "^9.1.2",
"recursive-diff": "^1.0.9", "recursive-diff": "^1.0.9",
"redis": "^4.7.0",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"skia-canvas": "^2.0.2", "skia-canvas": "^2.0.2",
"soap": "^1.1.10", "soap": "^1.1.10",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.5", "socket.io-adapter": "^2.5.5",
"ssh2-sftp-client": "^11.0.0", "ssh2-sftp-client": "^11.0.0",
"twilio": "^5.5.1", "twilio": "^5.6.0",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"winston": "^3.17.0", "winston": "^3.17.0",
"winston-cloudwatch": "^6.3.0", "winston-cloudwatch": "^6.3.0",
@@ -76,16 +73,14 @@
"xmlbuilder2": "^3.1.1" "xmlbuilder2": "^3.1.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.23.0", "@eslint/js": "^9.26.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2", "eslint": "^9.26.0",
"eslint": "^9.23.0", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react": "^7.37.4",
"globals": "^15.15.0", "globals": "^15.15.0",
"mock-require": "^3.0.3", "mock-require": "^3.0.3",
"p-limit": "^3.1.0", "p-limit": "^3.1.0",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"source-map-explorer": "^2.5.2",
"supertest": "^7.1.0", "supertest": "^7.1.0",
"vitest": "^3.1.1" "vitest": "^3.1.3"
} }
} }

View File

@@ -4,6 +4,7 @@ require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
}); });
// Commented out due to stability issues
if (process.env.NODE_ENV) { if (process.env.NODE_ENV) {
require("dd-trace").init({ require("dd-trace").init({
profiling: true, profiling: true,
@@ -16,7 +17,6 @@ const cors = require("cors");
const http = require("http"); const http = require("http");
const Redis = require("ioredis"); const Redis = require("ioredis");
const express = require("express"); const express = require("express");
const bodyParser = require("body-parser");
const compression = require("compression"); const compression = require("compression");
const cookieParser = require("cookie-parser"); const cookieParser = require("cookie-parser");
const { Server } = require("socket.io"); const { Server } = require("socket.io");
@@ -34,7 +34,6 @@ const {
DescribeReplicationGroupsCommand DescribeReplicationGroupsCommand
} = require("@aws-sdk/client-elasticache"); } = require("@aws-sdk/client-elasticache");
const { InstanceRegion } = require("./server/utils/instanceMgr"); const { InstanceRegion } = require("./server/utils/instanceMgr");
const StartStatusReporter = require("./server/utils/statusReporter");
const { registerCleanupTask, initializeCleanupManager } = require("./server/utils/cleanupManager"); const { registerCleanupTask, initializeCleanupManager } = require("./server/utils/cleanupManager");
const { loadEmailQueue } = require("./server/notifications/queues/emailQueue"); const { loadEmailQueue } = require("./server/notifications/queues/emailQueue");
@@ -118,6 +117,8 @@ const applyRoutes = ({ app }) => {
app.use("/cdk", require("./server/routes/cdkRoutes")); app.use("/cdk", require("./server/routes/cdkRoutes"));
app.use("/csi", require("./server/routes/csiRoutes")); app.use("/csi", require("./server/routes/csiRoutes"));
app.use("/payroll", require("./server/routes/payrollRoutes")); app.use("/payroll", require("./server/routes/payrollRoutes"));
app.use("/sso", require("./server/routes/ssoRoutes"));
app.use("/integrations", require("./server/routes/intergrationRoutes"));
// Default route for forbidden access // Default route for forbidden access
app.get("/", (req, res) => { app.get("/", (req, res) => {
@@ -391,13 +392,6 @@ const main = async () => {
applyRoutes({ app }); applyRoutes({ app });
redisSocketEvents({ io: ioRedis, redisHelpers, ioHelpers, logger }); redisSocketEvents({ io: ioRedis, redisHelpers, ioHelpers, logger });
const StatusReporter = StartStatusReporter();
registerCleanupTask(async () => {
if (isFunction(StatusReporter?.end)) {
StatusReporter.end();
}
});
try { try {
await server.listen(port); await server.listen(port);
logger.log(`Server started on port ${port}`, "INFO", "api"); logger.log(`Server started on port ${port}`, "INFO", "api");

View File

@@ -217,7 +217,7 @@ exports.PbsExportAp = async function (socket, { billids, txEnvelope }) {
socket.emit("ap-export-success", billid); socket.emit("ap-export-success", billid);
} else { } else {
CdkBase.createLogEvent(socket, "ERROR", `Export was not succesful.`); CdkBase.createLogEvent(socket, "ERROR", `Export was not successful.`);
socket.emit("ap-export-failure", { socket.emit("ap-export-failure", {
billid, billid,
error: AccountPostingChange.Message error: AccountPostingChange.Message

View File

@@ -105,14 +105,14 @@ exports.PbsSelectedCustomer = async function PbsSelectedCustomer(socket, selecte
socket.emit("export-success", socket.JobData.id); socket.emit("export-success", socket.JobData.id);
} else { } else {
CdkBase.createLogEvent(socket, "ERROR", `Export was not succesful.`); CdkBase.createLogEvent(socket, "ERROR", `Export was not successful.`);
} }
} catch (error) { } catch (error) {
CdkBase.createLogEvent(socket, "ERROR", `Error encountered in CdkSelectedCustomer. ${error}`); CdkBase.createLogEvent(socket, "ERROR", `Error encountered in CdkSelectedCustomer. ${error}`);
await InsertFailedExportLog(socket, error); await InsertFailedExportLog(socket, error);
} }
}; };
// Was Successful
async function CheckForErrors(socket, response) { async function CheckForErrors(socket, response) {
if (response.WasSuccessful === undefined || response.WasSuccessful === true) { if (response.WasSuccessful === undefined || response.WasSuccessful === true) {
CdkBase.createLogEvent(socket, "DEBUG", `Successful response from DMS. ${response.Message || ""}`); CdkBase.createLogEvent(socket, "DEBUG", `Successful response from DMS. ${response.Message || ""}`);

View File

@@ -68,7 +68,7 @@ exports.default = async (req, res) => {
return; return;
} }
if (process.env.NODE_ENV === "PRODUCTION") { if (process.env.NODE_ENV === "production") {
res.sendStatus(200); res.sendStatus(200);
return; return;
} }

View File

@@ -2,7 +2,6 @@ const path = require("path");
const queries = require("../graphql-client/queries"); const queries = require("../graphql-client/queries");
const moment = require("moment-timezone"); const moment = require("moment-timezone");
const converter = require("json-2-csv"); const converter = require("json-2-csv");
const _ = require("lodash");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const fs = require("fs"); const fs = require("fs");
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager"); const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");

View File

@@ -3,4 +3,5 @@ exports.autohouse = require("./autohouse").default;
exports.chatter = require("./chatter").default; exports.chatter = require("./chatter").default;
exports.claimscorp = require("./claimscorp").default; exports.claimscorp = require("./claimscorp").default;
exports.kaizen = require("./kaizen").default; exports.kaizen = require("./kaizen").default;
exports.usageReport = require("./usageReport").default; exports.usageReport = require("./usageReport").default;
exports.podium = require("./podium").default;

211
server/data/podium.js Normal file
View File

@@ -0,0 +1,211 @@
const path = require("path");
const queries = require("../graphql-client/queries");
const moment = require("moment-timezone");
const converter = require("json-2-csv");
const logger = require("../utils/logger");
const fs = require("fs");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
let Client = require("ssh2-sftp-client");
const client = require("../graphql-client/graphql-client").client;
const { sendServerEmail } = require("../email/sendemail");
const ftpSetup = {
host: process.env.PODIUM_HOST,
port: process.env.PODIUM_PORT,
username: process.env.PODIUM_USER,
password: process.env.PODIUM_PASSWORD,
debug:
process.env.NODE_ENV !== "production"
? (message, ...data) => logger.log(message, "DEBUG", "api", null, data)
: () => {},
algorithms: {
serverHostKey: ["ssh-rsa", "ssh-dss", "rsa-sha2-256", "rsa-sha2-512", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"]
}
};
exports.default = async (req, res) => {
// Only process if in production environment.
if (process.env.NODE_ENV !== "production") {
res.sendStatus(403);
return;
}
// Only process if the appropriate token is provided.
if (req.headers["x-imex-auth"] !== process.env.AUTOHOUSE_AUTH_TOKEN) {
res.sendStatus(401);
return;
}
// Send immediate response and continue processing.
res.status(202).json({
success: true,
message: "Processing request ...",
timestamp: new Date().toISOString()
});
try {
logger.log("podium-start", "DEBUG", "api", null, null);
const allCSVResults = [];
const allErrors = [];
const { bodyshops } = await client.request(queries.GET_PODIUM_SHOPS); //Query for the List of Bodyshop Clients.
const specificShopIds = req.body.bodyshopIds; // ['uuid];
const { start, end, skipUpload } = req.body; //YYYY-MM-DD
const shopsToProcess =
specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.includes(shop.id)) : bodyshops;
logger.log("podium-shopsToProcess-generated", "DEBUG", "api", null, null);
if (shopsToProcess.length === 0) {
logger.log("podium-shopsToProcess-empty", "DEBUG", "api", null, null);
return;
}
await processShopData(shopsToProcess, start, end, skipUpload, allCSVResults, allErrors);
await sendServerEmail({
subject: `Podium Report ${moment().format("MM-DD-YY")}`,
text: `Errors:\n${JSON.stringify(allErrors, null, 2)}\n\nUploaded:\n${JSON.stringify(
allCSVResults.map((x) => ({
imexshopid: x.imexshopid,
filename: x.filename,
count: x.count,
result: x.result
})),
null,
2
)}`
});
logger.log("podium-end", "DEBUG", "api", null, null);
} catch (error) {
logger.log("podium-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
}
};
async function processShopData(shopsToProcess, start, end, skipUpload, allCSVResults, allErrors) {
for (const bodyshop of shopsToProcess) {
const erroredJobs = [];
try {
logger.log("podium-start-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname
});
const { jobs, bodyshops_by_pk } = await client.request(queries.PODIUM_QUERY, {
bodyshopid: bodyshop.id,
start: start ? moment(start).startOf("day") : moment().subtract(2, "days").startOf("day"),
...(end && { end: moment(end).endOf("day") })
});
const podiumObject = jobs.map((j) => {
return {
"Podium Account ID": bodyshops_by_pk.podiumid,
"First Name": j.ownr_co_nm ? null : j.ownr_fn,
"Last Name": j.ownr_co_nm ? j.ownr_co_nm : j.ownr_ln,
"SMS Number": null,
"Phone 1": j.ownr_ph1,
"Phone 2": j.ownr_ph2,
Email: j.ownr_ea,
"Delivered Date":
(j.actual_delivery && moment(j.actual_delivery).tz(bodyshop.timezone).format("MM/DD/YYYY")) || ""
};
});
if (erroredJobs.length > 0) {
logger.log("podium-failed-jobs", "ERROR", "api", bodyshop.id, {
count: erroredJobs.length,
jobs: JSON.stringify(erroredJobs.map((j) => j.job.ro_number))
});
}
const csvObj = {
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
csv: converter.json2csv(podiumObject, { emptyFieldValue: "" }),
filename: `${bodyshop.podiumid}-${moment().format("YYYYMMDDTHHMMss")}.csv`,
count: podiumObject.length
};
if (skipUpload) {
fs.writeFileSync(`./logs/${csvObj.filename}`, csvObj.csv);
} else {
await uploadViaSFTP(csvObj);
}
allCSVResults.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
podiumid: bodyshop.podiumid,
count: csvObj.count,
filename: csvObj.filename,
result: csvObj.result
});
logger.log("podium-end-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname
});
} catch (error) {
//Error at the shop level.
logger.log("podium-error-shop", "ERROR", "api", bodyshop.id, { error: error.message, stack: error.stack });
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
podiumid: bodyshop.podiumid,
fatal: true,
errors: [error.toString()]
});
} finally {
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
podiumid: bodyshop.podiumid,
errors: erroredJobs.map((ej) => ({
ro_number: ej.job?.ro_number,
jobid: ej.job?.id,
error: ej.error
}))
});
}
}
}
async function uploadViaSFTP(csvObj) {
const sftp = new Client();
sftp.on("error", (errors) =>
logger.log("podium-sftp-connection-error", "ERROR", "api", csvObj.bodyshopid, {
error: errors.message,
stack: errors.stack
})
);
try {
//Connect to the FTP and upload all.
await sftp.connect(ftpSetup);
try {
csvObj.result = await sftp.put(Buffer.from(csvObj.xml), `${csvObj.filename}`);
logger.log("podium-sftp-upload", "DEBUG", "api", csvObj.bodyshopid, {
imexshopid: csvObj.imexshopid,
filename: csvObj.filename,
result: csvObj.result
});
} catch (error) {
logger.log("podium-sftp-upload-error", "ERROR", "api", csvObj.bodyshopid, {
filename: csvObj.filename,
error: error.message,
stack: error.stack
});
throw error;
}
} catch (error) {
logger.log("podium-sftp-error", "ERROR", "api", csvObj.bodyshopid, {
error: error.message,
stack: error.stack
});
throw error;
} finally {
sftp.end();
}
}

View File

@@ -55,7 +55,12 @@ exports.default = async (req, res) => {
const csv = converter.json2csv(shopList, { emptyFieldValue: "" }); const csv = converter.json2csv(shopList, { emptyFieldValue: "" });
emailer emailer
.sendTaskEmail({ .sendTaskEmail({
to: ["patrick.fic@convenient-brands.com", "bradley.rhoades@convenient-brands.com", "jrome@rometech.com"], to: [
"patrick.fic@convenient-brands.com",
"bradley.rhoades@convenient-brands.com",
"jrome@rometech.com",
"ivana@imexsystems.ca"
],
subject: `RO Usage Report - ${moment().format("MM/DD/YYYY")}`, subject: `RO Usage Report - ${moment().format("MM/DD/YYYY")}`,
text: ` text: `
Usage Report for ${moment().format("MM/DD/YYYY")} for Rome Online Customers. Usage Report for ${moment().format("MM/DD/YYYY")} for Rome Online Customers.

View File

@@ -1,5 +1,3 @@
const moment = require("moment");
const { default: RenderInstanceManager } = require("../utils/instanceMgr");
const { header, end, start } = require("./html"); const { header, end, start } = require("./html");
// Required Strings // Required Strings
@@ -7,19 +5,6 @@ const { header, end, start } = require("./html");
// - subHeader - The subheader of the email // - subHeader - The subheader of the email
// - body - The body of the email // - body - The body of the email
// Optional Strings (Have default values)
// - footer - The footer of the email
// - dateLine - The date line of the email
const defaultFooter = () => {
return RenderInstanceManager({
imex: "ImEX Online Collision Repair Management System",
rome: "Rome Technologies"
});
};
const now = () => moment().format("MM/DD/YYYY @ hh:mm a");
/** /**
* Generate the email template * Generate the email template
* @param strings * @param strings
@@ -32,81 +17,48 @@ const generateEmailTemplate = (strings) => {
header + header +
start + start +
` `
<table class="row"> <!-- Report Title -->
<tbody> ${
<tr> strings.header &&
<th class="small-12 large-12 columns first last"> `
<table> <table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<tbody> <th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 8px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr> <tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<td> <h6 style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; color: inherit; word-wrap: normal; font-weight: normal; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 23px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; text-align: center;"><strong style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">${strings.header}</strong></h6>
<h6 style="text-align:left"><strong>${strings.header}</strong></h6> </td></tr>
</td> </tbody></table></th>
</tr> </tr></tbody></table>
<tr> `
<td> }
<p style="font-size:90%">${strings.subHeader}</p> ${
</td> strings.subHeader &&
</tr> `
</tbody> <table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
</table> <th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
</th> <tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
</tr> <p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 95%;">${strings.subHeader}</p>
</tbody> </td></tr>
</table> </tbody></table></th>
</tr></tbody></table>
`
}
<!-- End Report Title --> <!-- End Report Title -->
<!-- Task Detail --> ${
<table class="row"> strings.body &&
<tbody> `
<tr> <!-- Report Detail -->
<th class="small-12 large-12 columns first last"> <table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<table> <th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tbody> <tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<tr> ${strings.body}
<td>${strings.body}</td> </td></tr>
</tr> </tbody></table></th>
</tbody> </tr></tbody></table>
</table> <!-- End Report Detail -->
</th> `
</tr> }
</tbody> ` +
</table> end(strings.dateLine)
<!-- End Task Detail -->
<!-- Footer -->
<table class="row collapsed footer" id="non-printable">
<tbody>
<tr>
<th class="small-3 large-3 columns first">
<table>
<tbody>
<tr>
<td><p style="font-size:70%; padding-right:10px">${strings?.dateLine || now()}</p></td>
</tr>
</tbody>
</table>
</th>
<th class="small-6 large-6 columns">
<table>
<tbody>
<tr>
<td><p style="font-size:70%; text-align:center">${strings?.footer || defaultFooter()}</p></td>
</tr>
</tbody>
</table>
</th>
<th class="small-3 large-3 columns last">
<table>
<tbody>
<tr>
<td><p style="font-size:70%">&nbsp;</p></td>
</tr>
</tbody>
</table>
</th>
</tr>
</tbody>
</table>` +
end
); );
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -40,7 +40,9 @@ const logEmail = async (req, email) => {
to: req?.body?.to, to: req?.body?.to,
cc: req?.body?.cc, cc: req?.body?.cc,
subject: req?.body?.subject, subject: req?.body?.subject,
email email,
errorMessage: error?.message,
errorStack: error?.stack
// info, // info,
}); });
} }
@@ -68,6 +70,7 @@ const sendServerEmail = async ({ subject, text }) => {
] ]
} }
}, },
// eslint-disable-next-line no-unused-vars
(err, info) => { (err, info) => {
logger.log("server-email-failure", err ? "error" : "debug", null, null, { logger.log("server-email-failure", err ? "error" : "debug", null, null, {
message: err?.message, message: err?.message,
@@ -80,6 +83,108 @@ const sendServerEmail = async ({ subject, text }) => {
} }
}; };
const sendWelcomeEmail = async ({ to, resetLink, dateLine, features, bcc }) => {
try {
await mailer.sendMail({
from: InstanceManager({
imex: `ImEX Online <noreply@imex.online>`,
rome: `Rome Online <noreply@romeonline.io>`
}),
to,
bcc,
subject: InstanceManager({
imex: "Welcome to the ImEX Online platform.",
rome: "Welcome to the Rome Online platform."
}),
html: generateEmailTemplate({
header: InstanceManager({
imex: "Welcome to the ImEX Online platform.",
rome: "Welcome to the Rome Online platform."
}),
subHeader: `Your ${InstanceManager({imex: features?.allAccess ? "ImEX Online": "ImEX Lite", rome: features?.allAccess ? "RO Manager" : "RO Basic"})} shop setup has been completed, and this email will include all the information you need to begin.`,
body: `
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">To finish setting up your account, visit this link and enter your desired password. <a href=${resetLink} style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">Reset Password</a></p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">To access your ${InstanceManager({imex: features.allAccess ? "ImEX Online": "ImEX Lite", rome: features.allAccess ? "RO Manager" : "RO Basic"})} shop, visit <a href=${InstanceManager({imex: "https://imex.online/", rome: "https://romeonline.io/"})} style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">${InstanceManager({imex: "imex.online", rome: "romeonline.io"})}</a>. Your username is your email, and your password is what you previously set up. Contact support for additional logins.</p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
${InstanceManager({
rome: `
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">To push estimates over from your estimating system, you must download the Web-Est EMS Unzipper & Rome Online Partner (Computers using Windows only). Here are some steps to help you get started.</p>
</td><tr>
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<ul style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 1%; padding-left: 30px;">
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">Download and set up the Web-Est EMS Unzipper - <a href="https://help.imex.online/en/article/how-to-set-up-the-ems-unzip-downloader-on-web-est-n9hbcv/" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">How to setup the EMS Unzip Downloader on Web-Est</a></li>
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">Download and set up Rome Online Partner - <a href="https://help.imex.online/en/article/setting-up-the-rome-online-partner-1xsw8tb/" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">Setting up the Rome Online Partner</a></li>
</ul>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">Once you successfully set up the partner, now it's time to do some initial in-product items: Please note, <b>an estimate must be exported from the estimating platform to use tours.</b></p>
</td><tr>
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<ul style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 1%; padding-left: 30px;">
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">Send estimate from Web-Est to RO Basic - <a href="https://help.imex.online/en/article/how-to-send-estimates-from-web-est-to-the-management-system-ox0h9a/" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">How to setup the EMS Unzip Downloader on Web-Est</a></li>
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">Once completed, learn how to use RO Basic by accessing the tours at the bottom middle of the screen (labeled “Training Tours”). These walkthroughs will show you how to navigate from creating an RO to closing an RO - <a href="https://www.youtube.com/watch?v=gcbSe5med0I" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">ROME Collision Management Youtube Training Videos</a></li>
</ul>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">If you need any assistance with setting up the programs, or if you want a dedicated Q&A session with one of our customer success specialists, schedule by clicking this link - <a href="https://rometech.zohobookings.com/#/PSAT" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">Rome Basic Training Booking</a></p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">If you have additional questions or need any support, feel free to use the RO Basic support chat (blue chat box located in the bottom right corner) or give us a call at <a href="tel:14103576700" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">(410) 357-6700</a>. We are here to help make your experience seamless!</p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
`
})}
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">In addition to the training tour, you can also book a live one-on-one demo to see exactly how our system can help streamline the repair process at your shop, schedule by clicking this link - <a href="https://outlook.office.com/bookwithme/user/0aa3ae2c6d59497d9f93fb72479848dc@imexsystems.ca/meetingtype/Qy7CsXl5MkuUJ0NRD7B1AA2?anonymous&ep=mlink" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">${InstanceManager({imex: "ImEX Lite", rome: "Rome Basic"})} Demo Booking</a></p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 8px; width: 734px; padding-left: 0px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">Thanks,</p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 8px; width: 734px; padding-left: 0px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">The ${InstanceManager({imex: "ImEX Online", rome: "Rome Online"})} Team</p>
`,
dateLine
})
});
} catch (error) {
logger.log("server-email-failure", "error", null, null, { error });
}
};
const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachments }) => { const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachments }) => {
try { try {
mailer.sendMail( mailer.sendMail(
@@ -93,6 +198,7 @@ const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachmen
...(type === "text" ? { text } : { html }), ...(type === "text" ? { text } : { html }),
attachments: attachments || null attachments: attachments || null
}, },
// eslint-disable-next-line no-unused-vars
(err, info) => { (err, info) => {
// (message, type, user, record, meta // (message, type, user, record, meta
logger.log("server-email", err ? "error" : "debug", null, null, { message: err?.message, stack: err?.stack }); logger.log("server-email", err ? "error" : "debug", null, null, { message: err?.message, stack: err?.stack });
@@ -143,22 +249,20 @@ const sendEmail = async (req, res) => {
to: req.body.to, to: req.body.to,
cc: req.body.cc, cc: req.body.cc,
subject: req.body.subject, subject: req.body.subject,
attachments: attachments: [
[ ...(req.body.attachments &&
...((req.body.attachments && req.body.attachments.map((a) => {
req.body.attachments.map((a) => {
return {
filename: a.filename,
path: a.path
};
})) ||
[]),
...downloadedMedia.map((a) => {
return { return {
path: a filename: a.filename,
path: a.path
}; };
}) })),
] || null, ...downloadedMedia.map((a) => {
return {
path: a
};
})
],
html: isObject(req.body?.templateStrings) ? generateEmailTemplate(req.body.templateStrings) : req.body.html, html: isObject(req.body?.templateStrings) ? generateEmailTemplate(req.body.templateStrings) : req.body.html,
ses: { ses: {
// optional extra arguments for SendRawEmail // optional extra arguments for SendRawEmail
@@ -273,6 +377,7 @@ ${body.bounce?.bouncedRecipients.map(
)} )}
` `
}, },
// eslint-disable-next-line no-unused-vars
(err, info) => { (err, info) => {
logger.log("sns-error", err ? "error" : "debug", "api", null, { logger.log("sns-error", err ? "error" : "debug", "api", null, {
errorMessage: err?.message, errorMessage: err?.message,
@@ -294,5 +399,6 @@ module.exports = {
sendEmail, sendEmail,
sendServerEmail, sendServerEmail,
sendTaskEmail, sendTaskEmail,
emailBounce emailBounce,
sendWelcomeEmail
}; };

View File

@@ -17,11 +17,13 @@ const { formatTaskPriority } = require("../notifications/stringHelpers");
const tasksEmailQueue = taskEmailQueue(); const tasksEmailQueue = taskEmailQueue();
// Cleanup function for the Tasks Email Queue // Cleanup function for the Tasks Email Queue
// eslint-disable-next-line no-unused-vars
const tasksEmailQueueCleanup = async () => { const tasksEmailQueueCleanup = async () => {
try { try {
// Example async operation // Example async operation
// console.log("Performing Tasks Email Reminder process cleanup..."); // console.log("Performing Tasks Email Reminder process cleanup...");
await new Promise((resolve) => tasksEmailQueue.destroy(() => resolve())); await new Promise((resolve) => tasksEmailQueue.destroy(() => resolve()));
// eslint-disable-next-line no-unused-vars
} catch (err) { } catch (err) {
// console.error("Tasks Email Reminder process cleanup failed:", err); // console.error("Tasks Email Reminder process cleanup failed:", err);
} }
@@ -254,10 +256,15 @@ const tasksRemindEmail = async (req, res) => {
header: `${allTasks.length} Tasks require your attention`, header: `${allTasks.length} Tasks require your attention`,
subHeader: `Please click on the Tasks below to view the Task.`, subHeader: `Please click on the Tasks below to view the Task.`,
dateLine, dateLine,
body: `<ul> body: `
<ul style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; margin: 1%; padding-left: 30px;">
${allTasks ${allTasks
.map((task) => .map((task) =>
`<li><a href="${InstanceEndpoints()}/manage/tasks/alltasks?taskid=${task.id}">${task.title} - Priority: ${formatTaskPriority(task.priority)} ${task.due_date ? `${formatDate(task.due_date)}` : ""} | Bodyshop: ${task.bodyshop.shopname}</a></li>`.trim() `
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">
<a href="${InstanceEndpoints()}/manage/tasks/alltasks?taskid=${task.id}" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">${task.title} - Priority: ${formatTaskPriority(task.priority)} ${task.due_date ? `${formatDate(task.due_date)}` : ""} | Bodyshop: ${task.bodyshop.shopname}</a>
</li>
`.trim()
) )
.join("")} .join("")}
</ul>` </ul>`

View File

@@ -1,14 +1,10 @@
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const admin = require("firebase-admin");
const logger = require("../utils/logger");
//const { sendProManagerWelcomeEmail } = require("../email/sendemail");
const client = require("../graphql-client/graphql-client").client;
const serviceAccount = require(process.env.FIREBASE_ADMINSDK_JSON); const serviceAccount = require(process.env.FIREBASE_ADMINSDK_JSON);
//const generateEmailTemplate = require("../email/generateTemplate"); const admin = require("firebase-admin");
const moment = require("moment-timezone");
const logger = require("../utils/logger");
const client = require("../graphql-client/graphql-client").client;
const { sendWelcomeEmail } = require("../email/sendemail");
const { GET_USER_BY_EMAIL } = require("../graphql-client/queries");
admin.initializeApp({ admin.initializeApp({
credential: admin.credential.cert(serviceAccount), credential: admin.credential.cert(serviceAccount),
@@ -201,6 +197,94 @@ const unsubscribe = async (req, res) => {
} }
}; };
const getWelcomeEmail = async (req, res) => {
const { authid, email, bcc } = req.body;
try {
// Fetch user from Firebase
const userRecord = await admin.auth().getUser(authid);
if (!userRecord) {
throw { status: 404, message: "User not found in Firebase." };
}
// Fetch user data from the database using GraphQL
const dbUserResult = await client.request(GET_USER_BY_EMAIL, { email: email.toLowerCase() });
const dbUser = dbUserResult?.users?.[0];
if (!dbUser) {
throw { status: 404, message: "User not found in database." };
}
// Validate email before proceeding
if (!dbUser.validemail) {
logger.log("admin-send-welcome-email-skip", "debug", req.user.email, null, {
message: "User email is not valid, skipping email.",
email
});
return res.status(200).json({ message: "User email is not valid, email not sent." });
}
// Generate password reset link
const resetLink = await admin.auth().generatePasswordResetLink(dbUser.email);
// Send welcome email
await sendWelcomeEmail({
to: dbUser.email,
resetLink,
dateLine: moment().tz(dbUser.associations?.[0]?.bodyshop?.timezone).format("MM/DD/YYYY @ hh:mm a"),
features: dbUser.associations?.[0]?.bodyshop?.features,
bcc
});
// Log success and return response
logger.log("admin-send-welcome-email", "debug", req.user.email, null, {
request: req.body,
ioadmin: true,
emailSentTo: email
});
return res.status(200).json({ message: "Welcome email sent successfully." });
} catch (error) {
logger.log("admin-send-welcome-email-error", "ERROR", req.user.email, null, { error });
if (!res.headersSent) {
return res.status(error.status || 500).json({
message: error.message || "Error sending welcome email.",
error
});
}
}
};
const getResetLink = async (req, res) => {
const { authid, email } = req.body;
logger.log("admin-reset-link", "debug", req.user.email, null, { authid, email });
try {
// Fetch user from Firebase
const userRecord = await admin.auth().getUser(authid);
if (!userRecord) {
throw { status: 404, message: "User not found in Firebase." };
}
// Generate password reset link
const resetLink = await admin.auth().generatePasswordResetLink(email);
// Log success and return response
logger.log("admin-reset-link-success", "debug", req.user.email, null, {
request: req.body,
ioadmin: true
});
return res.status(200).json({ message: "Reset link generated successfully.", resetLink });
} catch (error) {
return res.status(error.status || 500).json({
message: error.message || "Error generating reset link.",
error
});
}
};
module.exports = { module.exports = {
admin, admin,
createUser, createUser,
@@ -208,23 +292,7 @@ module.exports = {
getUser, getUser,
sendNotification, sendNotification,
subscribe, subscribe,
unsubscribe unsubscribe,
getWelcomeEmail,
getResetLink
}; };
//Admin claims code.
// const uid = "JEqqYlsadwPEXIiyRBR55fflfko1";
// admin
// .auth()
// .getUser(uid)
// .then((user) => {
// console.log(user);
// admin.auth().setCustomUserClaims(uid, {
// ioadmin: true,
// "https://hasura.io/jwt/claims": {
// "x-hasura-default-role": "debug",
// "x-hasura-allowed-roles": ["admin"],
// "x-hasura-user-id": uid,
// },
// });
// });

View File

@@ -1,17 +1,19 @@
const GraphQLClient = require("graphql-request").GraphQLClient; const GraphQLClient = require("graphql-request").GraphQLClient;
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
//New bug introduced with Graphql Request. //New bug introduced with Graphql Request.
// https://github.com/prisma-labs/graphql-request/issues/206 // https://github.com/prisma-labs/graphql-request/issues/206
// const { Headers } = require("cross-fetch"); // const { Headers } = require("cross-fetch");
// global.Headers = global.Headers || Headers; // global.Headers = global.Headers || Headers;
exports.client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, { const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {
headers: { headers: {
"x-hasura-admin-secret": process.env.HASURA_ADMIN_SECRET "x-hasura-admin-secret": process.env.HASURA_ADMIN_SECRET
} }
}); });
exports.unauthclient = new GraphQLClient(process.env.GRAPHQL_ENDPOINT); const unauthorizedClient = new GraphQLClient(process.env.GRAPHQL_ENDPOINT);
module.exports = {
client,
unauthorizedClient
};

View File

@@ -9,6 +9,7 @@ query FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID($mssid: String!, $phone: String!) {
} }
}`; }`;
// Unused
exports.GET_JOB_BY_RO_NUMBER = ` exports.GET_JOB_BY_RO_NUMBER = `
query GET_JOB_BY_RO_NUMBER($ro_number: String!) { query GET_JOB_BY_RO_NUMBER($ro_number: String!) {
jobs(where:{ro_number:{_eq:$ro_number}}) { jobs(where:{ro_number:{_eq:$ro_number}}) {
@@ -1323,6 +1324,27 @@ exports.KAIZEN_QUERY = `query KAIZEN_EXPORT($start: timestamptz, $bodyshopid: uu
} }
}`; }`;
exports.PODIUM_QUERY = `query PODIUM_EXPORT($start: timestamptz, $bodyshopid: uuid!, $end: timestamptz) {
bodyshops_by_pk(id: $bodyshopid){
id
shopname
podiumid
timezone
}
jobs(where: {_and: [{converted: {_eq: true}}, {actual_delivery: {_gt: $start}}, {actual_delivery: {_lte: $end}}, {shopid: {_eq: $bodyshopid}}, {_or: [{ownr_ph1: {_is_null: false}}, {ownr_ea: {_is_null: false}}]}]}) {
actual_delivery
id
created_at
ro_number
ownr_fn
ownr_ln
ownr_co_nm
ownr_ph1
ownr_ph2
ownr_ea
}
}`;
exports.UPDATE_JOB = ` exports.UPDATE_JOB = `
mutation UPDATE_JOB($jobId: uuid!, $job: jobs_set_input!) { mutation UPDATE_JOB($jobId: uuid!, $job: jobs_set_input!) {
update_jobs(where: { id: { _eq: $jobId } }, _set: $job) { update_jobs(where: { id: { _eq: $jobId } }, _set: $job) {
@@ -1752,6 +1774,7 @@ exports.QUERY_JOB_COSTING_DETAILS_MULTI = ` query QUERY_JOB_COSTING_DETAILS_MULT
} }
}`; }`;
// Exists in Commented out Query
exports.INSERT_IOEVENT = ` mutation INSERT_IOEVENT($event: ioevents_insert_input!) { exports.INSERT_IOEVENT = ` mutation INSERT_IOEVENT($event: ioevents_insert_input!) {
insert_ioevents_one(object: $event) { insert_ioevents_one(object: $event) {
id id
@@ -1848,6 +1871,16 @@ exports.GET_KAIZEN_SHOPS = `query GET_KAIZEN_SHOPS($imexshopid: [String]) {
} }
}`; }`;
exports.GET_PODIUM_SHOPS = `query GET_PODIUM_SHOPS {
bodyshops(where: {podiumid: {_is_null: false}, _or: {podiumid: {_neq: ""}}}){
id
shopname
podiumid
imexshopid
timezone
}
}`;
exports.DELETE_ALL_DMS_VEHICLES = `mutation DELETE_ALL_DMS_VEHICLES{ exports.DELETE_ALL_DMS_VEHICLES = `mutation DELETE_ALL_DMS_VEHICLES{
delete_dms_vehicles(where: {}) { delete_dms_vehicles(where: {}) {
affected_rows affected_rows
@@ -2771,6 +2804,7 @@ exports.GET_BODYSHOP_BY_ID = `
imexshopid imexshopid
intellipay_config intellipay_config
state state
notification_followers
} }
} }
`; `;
@@ -2841,6 +2875,96 @@ query GET_JOBID_BY_MERCHANTID_RONUMBER($merchantID: String!, $roNumber: String!)
bodyshop { bodyshop {
id id
intellipay_config intellipay_config
email
} }
} }
}`; }`;
exports.GET_BODYSHOP_BY_MERCHANT_ID = `
query GET_BODYSHOP_BY_MERCHANTID($merchantID: String!) {
bodyshops(where: {intellipay_merchant_id: {_eq: $merchantID}}) {
id
email
}
}`;
exports.GET_USER_BY_EMAIL = `
query GET_USER_BY_EMAIL($email: String!) {
users(where: {email: {_eq: $email}}) {
email
validemail
associations {
id
shopid
bodyshop {
id
convenient_company
features
timezone
}
}
}
}`;
// Define the GraphQL query to get a job by RO number and shop ID
exports.GET_JOB_BY_RO_NUMBER_AND_SHOP_ID = `
query GET_JOB_BY_RO_NUMBER_AND_SHOP_ID($roNumber: String!, $shopId: uuid!) {
jobs(where: {ro_number: {_eq: $roNumber}, shopid: {_eq: $shopId}}, limit: 1) {
id
shopid
bodyshop {
timezone
}
}
}
`;
// Define the mutation to insert a new document
exports.INSERT_NEW_DOCUMENT = `
mutation INSERT_NEW_DOCUMENT($docInput: [documents_insert_input!]!) {
insert_documents(objects: $docInput) {
returning {
id
name
key
}
}
}
`;
exports.INSERT_JOB_WATCHERS = `
mutation INSERT_JOB_WATCHERS($watchers: [job_watchers_insert_input!]!) {
insert_job_watchers(objects: $watchers, on_conflict: { constraint: job_watchers_pkey, update_columns: [] }) {
affected_rows
}
}
`;
exports.GET_NOTIFICATION_WATCHERS = `
query GET_NOTIFICATION_WATCHERS($shopId: uuid!, $employeeIds: [uuid!]!) {
associations(where: {
_and: [
{ shopid: { _eq: $shopId } },
{ active: { _eq: true } },
{ notifications_autoadd: { _eq: true } }
]
}) {
id
useremail
}
employees(where: { id: { _in: $employeeIds }, shopid: { _eq: $shopId }, active: { _eq: true } }) {
user_email
}
}
`;
exports.GET_JOB_WATCHERS_MINIMAL = `
query GET_JOB_WATCHERS_MINIMAL($jobid: uuid!) {
job_watchers(where: { jobid: { _eq: $jobid } }) {
user_email
user {
authid
}
}
}
`;

View File

@@ -0,0 +1,143 @@
// Notes: At the moment we take in RO Number, and ShopID. This is not very good considering the RO number can often be null, need
// to ask if it is possible that we just send the Job ID itself, this way we don't need to really care about the bodyshop, and we
// don't risk getting a null
const axios = require("axios");
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const { GET_JOB_BY_RO_NUMBER_AND_SHOP_ID, INSERT_NEW_DOCUMENT } = require("../../graphql-client/queries");
const { InstanceRegion } = require("../../utils/instanceMgr");
const moment = require("moment/moment");
const client = require("../../graphql-client/graphql-client").client;
const S3_BUCKET = process.env?.IMGPROXY_DESTINATION_BUCKET;
/**
* @description VSSTA integration route
* @type {string[]}
*/
const requiredParams = [
"shop_id",
"ro_nbr",
"pdf_download_link",
"company_api_key",
"scan_type",
"scan_time",
"technician",
"year",
"make",
"model"
];
const vsstaIntegrationRoute = async (req, res) => {
const { logger } = req;
if (!S3_BUCKET) {
logger.log("vssta-integration-missing-bucket", "error", "api", "vssta");
return res.status(500).json({ error: "Improper configuration" });
}
try {
const missingParams = requiredParams.filter((param) => !req.body[param]);
if (missingParams.length > 0) {
logger.log(`vssta-integration-missing-param`, "error", "api", "vssta", {
params: missingParams
});
return res.status(400).json({
error: "Missing required parameters",
missingParams
});
}
// technician, year, make, model, is also available.
const { shop_id, ro_nbr, pdf_download_link, scan_type, scan_time, company_api_key } = req.body;
// 1. Get the job record by ro_number and shop_id
const jobResult = await client.request(GET_JOB_BY_RO_NUMBER_AND_SHOP_ID, {
roNumber: ro_nbr,
shopId: shop_id
});
if (!jobResult.jobs || jobResult.jobs.length === 0) {
logger.log(`vssta-integration-missing-ro`, "error", "api", "vssta");
return res.status(404).json({ error: "Job not found" });
}
const job = jobResult.jobs[0];
// 2. Download the base64-encoded PDF string from the provided link
const pdfResponse = await axios.get(pdf_download_link, {
responseType: "text", // Expect base64 string
headers: {
"auth-token": company_api_key
}
});
// 3. Decode the base64 string to a PDF buffer
const base64String = pdfResponse.data.replace(/^data:application\/pdf;base64,/, "");
const pdfBuffer = Buffer.from(base64String, "base64");
// 4. Generate key for S3
const timestamp = moment(scan_time).tz(job.bodyshop.timezone).format("YYYYMMDD-HHmmss");
const fileName = `${timestamp}_VSSTA_${scan_type}`;
const s3Key = `${job.shopid}/${job.id}/${fileName.replace(/[^A-Z0-9]+/gi, "_")}.pdf`;
// 5. Generate presigned URL for S3 upload
const s3Client = new S3Client({ region: InstanceRegion() });
const putCommand = new PutObjectCommand({
Bucket: S3_BUCKET,
Key: s3Key,
ContentType: "application/pdf",
StorageClass: "INTELLIGENT_TIERING"
});
const presignedUrl = await getSignedUrl(s3Client, putCommand, { expiresIn: 360 });
// 6. Upload the decoded PDF to S3
await axios.put(presignedUrl, pdfBuffer, {
headers: { "Content-Type": "application/pdf" }
});
// 7. Create document record in database
const documentMeta = {
jobid: job.id,
uploaded_by: "VSSTA Integration",
name: fileName,
key: s3Key,
type: "application/pdf",
extension: "pdf",
bodyshopid: job.shopid,
size: pdfBuffer.length,
takenat: scan_time
};
const documentInsert = await client.request(INSERT_NEW_DOCUMENT, {
docInput: [documentMeta]
});
if (!documentInsert.insert_documents?.returning?.length) {
logger.log(`vssta-integration-failed-to-create-document-record`, "error", "api", "vssta", {
params: missingParams
});
return res.status(500).json({ error: "Failed to create document record" });
}
return res.status(200).json({
message: "VSSTA integration successful",
documentId: documentInsert.insert_documents.returning[0].id
});
} catch (error) {
logger.log(`vssta-integration-general`, "error", "api", "vssta", {
error: error?.message,
stack: error?.stack
});
return res.status(500).json({ error: error.message });
}
};
module.exports = vsstaIntegrationRoute;

View File

@@ -2,8 +2,11 @@ const handlePaymentValidationError = require("./handlePaymentValidationError");
const { const {
GET_JOBID_BY_MERCHANTID_RONUMBER, GET_JOBID_BY_MERCHANTID_RONUMBER,
INSERT_PAYMENT_RESPONSE, INSERT_PAYMENT_RESPONSE,
INSERT_NEW_PAYMENT INSERT_NEW_PAYMENT,
GET_BODYSHOP_BY_MERCHANT_ID
} = require("../../graphql-client/queries"); } = require("../../graphql-client/queries");
const { sendTaskEmail } = require("../../email/sendemail");
const getPaymentType = require("./getPaymentType"); const getPaymentType = require("./getPaymentType");
const moment = require("moment"); const moment = require("moment");
@@ -36,7 +39,34 @@ const handleInvoiceBasedPayment = async (values, logger, logMeta, res) => {
}); });
if (!result?.jobs?.length) { if (!result?.jobs?.length) {
return handlePaymentValidationError(res, logger, "intellipay-postback-job-not-found", "Job not found", logMeta); // Fetch bodyshop data
const bodyshop = await gqlClient.request(GET_BODYSHOP_BY_MERCHANT_ID, {
merchantID: values.merchantid
});
if (bodyshop?.bodyshops?.[0]) {
// Note: changed bodyshops to bodyshop to match query name
const email = bodyshop.bodyshops[0].email;
await sendTaskEmail({
to: email,
subject: `Failed to Insert Payment`,
text: `The system has attempted to insert a payment that was generated by your merchant terminal but could not find an associated invoice. Transaction details are below. Please input this payment to your system manually.\n\n${Object.keys(
values
)
.map((key) => `${key}: ${values[key]}`)
.join("\n")}`
});
}
return handlePaymentValidationError(
res,
logger,
"intellipay-postback-job-not-found",
"Job not found",
logMeta,
200
);
} }
const job = result.jobs[0]; const job = result.jobs[0];
@@ -77,18 +107,25 @@ const handleInvoiceBasedPayment = async (values, logger, logMeta, res) => {
}); });
// Create payment response record // Create payment response record
const responseResults = await gqlClient.request(INSERT_PAYMENT_RESPONSE, { const responseResults = await gqlClient
paymentResponse: { .request(INSERT_PAYMENT_RESPONSE, {
amount: values.total, paymentResponse: {
bodyshopid: bodyshop.id, amount: values.total,
paymentid: paymentResult.id, bodyshopid: bodyshop.id,
jobid: job.id, paymentid: paymentResult.insert_payments.returning[0].id,
declinereason: "Approved", jobid: job.id,
ext_paymentid: values.paymentid, declinereason: "Approved",
successful: true, ext_paymentid: values.paymentid,
response: values successful: true,
} response: values
}); }
})
.catch((err) => {
logger.log("intellipay-postback-invoice-response-error", "ERROR", "api", null, {
err,
...logMeta
});
});
logger.log("intellipay-postback-invoice-response-success", "DEBUG", "api", null, { logger.log("intellipay-postback-invoice-response-success", "DEBUG", "api", null, {
responseResults, responseResults,

View File

@@ -5,14 +5,15 @@
* @param logCode * @param logCode
* @param message * @param message
* @param logMeta * @param logMeta
* @param returnCode
* @returns {*} * @returns {*}
*/ */
const handlePaymentValidationError = (res, logger, logCode, message, logMeta) => { const handlePaymentValidationError = (res, logger, logCode, message, logMeta, returnCode) => {
logger.log(logCode, "ERROR", "api", null, { logger.log(logCode, "ERROR", "api", null, {
message, message,
...logMeta ...logMeta
}); });
return res.status(400).send(`Bad Request: ${message}`); return res.status(returnCode || 400).send(`Bad Request: ${message}`);
}; };
module.exports = handlePaymentValidationError; module.exports = handlePaymentValidationError;

View File

@@ -1,5 +1,6 @@
const { sendTaskEmail } = require("../../email/sendemail"); const { sendTaskEmail } = require("../../email/sendemail");
const generateEmailTemplate = require("../../email/generateTemplate"); const generateEmailTemplate = require("../../email/generateTemplate");
const { InstanceEndpoints } = require("../../utils/instanceMgr");
/** /**
* @description Send notification email to the user * @description Send notification email to the user
@@ -22,11 +23,9 @@ const sendPaymentNotificationEmail = async (userEmail, jobs, partialPayments, lo
body: jobs.jobs body: jobs.jobs
.map( .map(
(job) => (job) =>
`Reference: <a href="${InstanceEndpoints()}/manage/jobs/${job.id}">${job.ro_number || "N/A"}</a> | ${ `<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">Reference: <a href="${InstanceEndpoints()}/manage/jobs/${job.id}" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">${job.ro_number || "N/A"}</a> | ${job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()} | $${partialPayments.find((p) => p.jobid === job.id).amount}</p>`
job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()
} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()} | $${partialPayments.find((p) => p.jobid === job.id).amount}`
) )
.join("<br/>") .join("")
}) })
}); });
} catch (error) { } catch (error) {

View File

@@ -37,7 +37,9 @@ beforeEach(() => {
] ]
}) })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
id: "payment123" insert_payments: {
returning: [{ id: "payment123" }]
}
}) })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
insert_payment_response: { insert_payment_response: {
@@ -96,7 +98,8 @@ describe("handleInvoiceBasedPayment", () => {
mockLogger, mockLogger,
"intellipay-postback-job-not-found", "intellipay-postback-job-not-found",
"Job not found", "Job not found",
logMeta logMeta,
200
); );
}); });

View File

@@ -1,7 +1,5 @@
const Dinero = require("dinero.js"); const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries"); const queries = require("../graphql-client/queries");
// const adminClient = require("../graphql-client/graphql-client").client;
// const _ = require("lodash");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const InstanceMgr = require("../utils/instanceMgr").default; const InstanceMgr = require("../utils/instanceMgr").default;
@@ -45,9 +43,11 @@ exports.totalsSsu = async function (req, res) {
} }
}); });
if (result) { if (!result) {
res.status(200).send(); throw new Error("Failed to update job totals");
} }
res.status(200).send();
} catch (error) { } catch (error) {
logger.log("job-totals-ssu-USA-error", "ERROR", req?.user?.email, id, { logger.log("job-totals-ssu-USA-error", "ERROR", req?.user?.email, id, {
jobid: id, jobid: id,
@@ -58,7 +58,7 @@ exports.totalsSsu = async function (req, res) {
} }
}; };
//IMPORTANT*** These two functions MUST be mirrrored. //IMPORTANT*** These two functions MUST be mirrored.
async function TotalsServerSide(req, res) { async function TotalsServerSide(req, res) {
const { job, client } = req.body; const { job, client } = req.body;
await AtsAdjustmentsIfRequired({ job: job, client: client, user: req?.user }); await AtsAdjustmentsIfRequired({ job: job, client: client, user: req?.user });
@@ -133,6 +133,9 @@ async function TotalsServerSide(req, res) {
} }
} }
// Exported for testing purposes.
exports.TotalsServerSide = TotalsServerSide;
async function Totals(req, res) { async function Totals(req, res) {
const { job, id } = req.body; const { job, id } = req.body;

View File

@@ -0,0 +1,139 @@
const logger = require("../utils/logger");
const queries = require("../graphql-client/queries");
const moment = require("moment");
const { captureFixture } = require("./utils/seralizeHelper");
const { TotalsServerSide: totalsServerSideCA } = require("./job-totals"); // Canadian version (imex)
const { TotalsServerSide: totalsServerSideUS } = require("./job-totals-USA");
const InstanceMgr = require("../utils/instanceMgr").default;
const { uploadFileToS3 } = require("../utils/s3");
// requires two buckets be made per env, job-totals-test, job-totals-production, locally it will
// use `job-totals` in the owncloud stack
/**
* Returns the environment prefix based on NODE_ENV
* @returns {string}
*/
const getEnvPrefix = () => {
switch (process.env?.NODE_ENV) {
case "test":
return "test";
case "production":
return "production";
default:
return "test";
}
};
const envPrefix = getEnvPrefix();
const S3_BUCKET_NAME =
process.env?.NODE_ENV === "development"
? "imex-job-totals"
: InstanceMgr({
imex: `job-totals-${envPrefix}`,
rome: `job-totals-${envPrefix}-rome`
});
/**
* Generates a unique S3 key for the job totals file
* @param {string} jobId - The job ID
* @returns {string} - S3 key with timestamp
*/
const generateS3Key = (jobId) => `${jobId}-${moment().toISOString()}.json`;
/**
* Uploads job totals data to S3
* @param {object} data - The data to upload
* @param {string} jobId - The job ID
* @param {object} userInfo - User information for logging
* @returns {Promise<string>} - The S3 key
*/
const uploadJobTotalsToS3 = async (data, jobId, userInfo) => {
const key = generateS3Key(jobId);
try {
await uploadFileToS3({
bucketName: S3_BUCKET_NAME,
key: key,
content: JSON.stringify(data, null, 2),
contentType: "application/json"
});
logger.log(`Job totals uploaded successfully to ${key}`, "info", userInfo.email, jobId);
return key;
} catch (error) {
logger.log("Failed to upload job totals to S3", "error", userInfo.email, jobId, {
error: error?.message,
stack: error?.stack
});
throw error; // Re-throw for the main handler to catch
}
};
/**
* Fetches job data using GraphQL
* @param {object} client - GraphQL client
* @param {string} token - Bearer token
* @param {string} jobId - Job ID to fetch
* @returns {Promise<object>} - Job data
*/
const fetchJobData = async (client, token, jobId) => {
return client
.setHeaders({ Authorization: token })
.request(queries.GET_JOB_BY_PK, { id: jobId })
.then((response) => response.jobs_by_pk);
};
/**
* This function is used to capture job totals json files.
* @param {object} req - Express request
* @param {object} res - Express response
* @returns {Promise<void>}
*/
const jobTotalsRecorder = async (req, res) => {
const { id: jobId } = req.body;
const bearerToken = req.BearerToken;
const client = req.userGraphQLClient;
const userEmail = req?.user?.email;
logger.log("Starting job totals recording", "debug", userEmail, jobId);
try {
// Fetch job data
const jobData = await fetchJobData(client, bearerToken, jobId);
// Get the appropriate totals function based on instance
const totalsFunction = InstanceMgr({
imex: totalsServerSideCA,
rome: totalsServerSideUS
});
// Calculate the totals
const calculatedTotals = await totalsFunction({ body: { job: jobData, client }, req }, res, true);
// Prepare data for storage
const dataToSave = captureFixture(jobData, calculatedTotals);
// Upload to S3
await uploadJobTotalsToS3(dataToSave, jobId, { email: userEmail });
res.status(200).json({ success: true, message: "Job totals recorded successfully" });
} catch (error) {
logger.log("Failed to record job totals", "error", userEmail, jobId, {
error: error?.message,
stack: error?.stack
});
// Avoid sending response if it's already been sent
if (!res.headersSent) {
res.status(503).json({
success: false,
message: "Error processing job totals",
error: error.message
});
}
}
};
module.exports = jobTotalsRecorder;

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