Compare commits

..

114 Commits

Author SHA1 Message Date
Patrick Fic
4043bd3d33 Merged in feature/IO-3722-disable-contact-fortellis (pull request #3286)
IO-3722 Remove delivery date for bypass vehicles.

Approved-by: Dave Richer
2026-05-28 18:33:50 +00:00
Patrick Fic
c1c0b35c8f IO-3722 Remove delivery date for bypass vehicles. 2026-05-28 11:32:23 -07:00
Patrick Fic
4fd2f034a3 Merged in feature/IO-3722-disable-contact-fortellis (pull request #3283)
IO-3722 Remove customer lookup by Vehicle Owner.

Approved-by: Dave Richer
2026-05-28 16:55:21 +00:00
Patrick Fic
aa3b303fe9 IO-3722 Remove customer lookup by Vehicle Owner. 2026-05-28 09:53:40 -07:00
Patrick Fic
bd25245290 Merged in feature/IO-3722-disable-contact-fortellis (pull request #3281)
IO-3722 Fix undefined customer ref.
2026-05-27 21:19:02 +00:00
Patrick Fic
468ed23f73 IO-3722 Fix undefined customer ref. 2026-05-27 14:18:31 -07:00
Patrick Fic
6472b053ed Merged in feature/IO-3722-disable-contact-fortellis (pull request #3280)
Resolve inversed if statement.
2026-05-27 19:54:30 +00:00
Patrick Fic
322ebd3bc7 Resolve inversed if statement. 2026-05-27 12:46:09 -07:00
Patrick Fic
169070594c Merged in feature/IO-3722-disable-contact-fortellis (pull request #3279)
IO-3722 Add additional await.
2026-05-27 19:42:38 +00:00
Patrick Fic
0f800c5a4c IO-3722 Add additional await. 2026-05-27 12:40:41 -07:00
Dave Richer
0974e69a50 Merged in feature/IO-3722-disable-contact-fortellis (pull request #3277)
IO-3722 Disable contact API calls for Fortellis.
2026-05-27 18:36:51 +00:00
Patrick FIc
345a470731 IO-3722 Disable contact API calls for Fortellis. 2026-05-27 10:31:33 -07:00
Dave Richer
ebde2f1581 Merged in release/2026-05-22 (pull request #3257)
Release/2026 05 22
2026-05-25 12:45:19 +00:00
Allan Carr
a45808eb94 Merged in feature/IO-3710-Visual-Board-Vehicle-Color (pull request #3255)
IO-3710 Visual Board Vehicle Color

Approved-by: Dave Richer
2026-05-20 23:57:28 +00:00
Allan Carr
a2389b1f26 IO-3710 Visual Board Vehicle Color
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-20 13:42:35 -07:00
Dave
ab606a4266 release/2026-05-22 - Remove uncessary require 2026-05-20 14:46:52 -04:00
Patrick Fic
da317704c4 Merged in feature/IO-3712-disable-analytics (pull request #3252)
IO-3712 Disable analytics in client side.

Approved-by: Dave Richer
2026-05-20 18:10:39 +00:00
Patrick Fic
771573409f IO-3712 Disable analytics in client side. 2026-05-20 10:48:56 -07:00
Allan Carr
cb9ccb7e77 Merged in feature/IO-3707-Deduct-From-Labor-Enhanced-Payrol (pull request #3249)
IO-3707 Deduct from Labor Enhanced Payroll

Approved-by: Dave Richer
2026-05-20 16:54:09 +00:00
Allan Carr
a5d00d562c Merged in feature/IO-3699-Alt-Part-No-Expose (pull request #3250)
Feature/IO-3699 Alt Part No Expose

Approved-by: Dave Richer
2026-05-20 16:53:36 +00:00
Allan Carr
bdeeea0406 Merged in feature/IO-3710-Visual-Board-Vehicle-Color (pull request #3248)
IO-3710 Visual Board - Vehicle Color

Approved-by: Dave Richer
2026-05-20 16:52:10 +00:00
Allan Carr
297d8afa8a IO-3699 Prettier Run
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-19 18:44:36 -07:00
Allan Carr
3a12597c45 IO-3699 Alt Part # Exposed
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-19 18:43:44 -07:00
Allan Carr
72c96f14eb IO-3707 Prettier Run
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-19 18:35:26 -07:00
Allan Carr
de9d47272c IO-3707 Deduct from Labor Enhanced Payroll
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-19 18:34:19 -07:00
Allan Carr
3fd51f0140 IO-3710 Visual Board - Vehicle Color
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-19 16:01:38 -07:00
Dave
84ec68f142 release/2026-05-22 - Remove uncessary require 2026-05-14 11:03:40 -04:00
Patrick Fic
22af37e8f1 Merged in feature/IO-3692-remove-patrick (pull request #3246)
IO-3692 Remove patrick's email from all codebase.

Approved-by: Dave Richer
2026-05-14 15:02:06 +00:00
Patrick FIc
86affddc24 IO-3692 Remove patrick's email from all codebase. 2026-05-13 09:40:05 -07:00
Dave Richer
57fdffff09 Merged in feature/IO-2433-esignature (pull request #3244)
Feature/IO-2433 esignature
2026-05-13 16:10:17 +00:00
Dave
e74be56681 feature/IO-2433-esignature - Code review fixes 2026-05-13 12:09:18 -04:00
Dave
f5d33a2386 Merge branch 'master-AIO' into feature/IO-2433-esignature 2026-05-13 11:51:12 -04:00
Allan Carr
edc9ba33c5 Merged in hotfix/2026-05-12 (pull request #3243)
IO-3691 Job Totals Issues
2026-05-12 15:56:13 +00:00
Allan Carr
4586f32f38 Merged in feature/IO-3691-Jobs-Totals-Issues (pull request #3242)
IO-3691 Job Totals Issues
2026-05-12 15:46:59 +00:00
Allan Carr
281e50a43e Merged in feature/IO-3691-Jobs-Totals-Issues (pull request #3240)
IO-3691 Job Totals Issues

Approved-by: Patrick Fic
2026-05-12 15:39:57 +00:00
Allan Carr
7a50f2a2fe IO-3691 Job Totals Issues
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-11 22:11:31 -07:00
Dave Richer
0c83f796db Merged in hotfix/2026-05-11 (pull request #3239)
hotfix/2020hotfix/2026-05-11 - Fix so polling throws error on missing env var (logs error), and does not start poll, vs starting polling and logging error every 10 seconds
2026-05-11 21:27:46 +00:00
Dave Richer
237c575bab Merged in hotfix/2026-05-11 (pull request #3237)
hotfix/2020hotfix/2026-05-11 - Fix so polling throws error on missing env var (logs error), and does not start poll, vs starting polling and logging error every 10 seconds
2026-05-11 21:25:51 +00:00
Dave
a54e74a27d hotfix/2020hotfix/2026-05-11 - Fix so polling throws error on missing env var (logs error), and does not start poll, vs starting polling and logging error every 10 seconds 2026-05-11 17:24:48 -04:00
Dave
87797c7743 Merge branch 'master-AIO' into feature/IO-2433-esignature 2026-05-11 12:07:59 -04:00
Dave Richer
d227cacd68 Merged in release/2026-05-08 (pull request #3236)
Release/2026 05 08 into master-AIO - IO-3562, IO-3647, IO-3650, IO-3667, IO-3672, IO-3673, IO-3674, IO-3676, IO-3679, IO-3686, IO-3687, IO-3688, IO-3689
2026-05-09 00:54:18 +00:00
Allan Carr
ef4565d738 Merged in feature/IO-3689-Customer-List-Restriction (pull request #3234)
IO-3689 Customer List Restriction

Approved-by: Dave Richer
2026-05-08 18:45:21 +00:00
Allan Carr
74eeceacca IO-3689 Customer List Restriction
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-05-08 11:32:49 -07:00
Dave Richer
6e566e2f8a Merged in feature/IO-3688-Searchable-Referral-Source (pull request #3232)
feature/IO-3688-Searchable-Referral-Source - Implement (convert button)
2026-05-08 15:43:01 +00:00
Dave
80697a5259 feature/IO-3688-Searchable-Referral-Source - Implement (convert button) 2026-05-08 11:42:19 -04:00
Dave Richer
fe8d1f7e95 Merged in feature/IO-3688-Searchable-Referral-Source (pull request #3230)
feature/IO-3688-Searchable-Referral-Source - Implement
2026-05-08 14:41:56 +00:00
Dave
32f3143dca feature/IO-3688-Searchable-Referral-Source - Implement 2026-05-08 10:40:21 -04:00
Dave Richer
0ba207a499 Merged in feature/IO-3679-Tech-Console-Null-Error (pull request #3228)
feature/IO-3679-Tech-Console-Null-Error - fix
2026-05-07 14:42:17 +00:00
Dave
f849ea9d0a feature/IO-3679-Tech-Console-Null-Error - fix 2026-05-07 10:41:19 -04:00
Patrick Fic
e0b113e5d0 Merged in feature/IO-3686-pbs-enhancements (pull request #3224)
IO-3686 River city enhancements for AR customers and Contact Code

Approved-by: Dave Richer
2026-05-07 14:03:48 +00:00
Dave Richer
fc199279d1 Merged in feature/IO-3687-Grey-Scale-Invisible-text (pull request #3225)
feature/IO-3687-Grey-Scale-Invisible-text - implement
2026-05-06 20:46:14 +00:00
Dave
fcba77fe20 feature/IO-3687-Grey-Scale-Invisible-text - implement 2026-05-06 16:45:00 -04:00
Patrick Fic
f294eafde7 IO-3686 River city enhancements for AR customers and Contact Code 2026-05-06 11:57:53 -07:00
Dave Richer
e0f55b8e7a Merged in feature/IO-3673-Order-Parts-Receive-Bill-Bug (pull request #3222)
feature/IO-3673-Order-Parts-Receive-Bill-Bug - Fix

Approved-by: Allan Carr
2026-05-06 15:48:10 +00:00
Dave Richer
ef6aee0518 Merged in feature/IO-3676-Order-As-In-House-Quantity (pull request #3221)
feature/IO-3676-Order-As-In-House-Quantity - Fix

Approved-by: Allan Carr
2026-05-06 15:48:00 +00:00
Patrick Fic
d5e643b429 Updated terraform WAF. 2026-05-05 11:14:23 -07:00
Dave
88ae1fb1cc feature/IO-3673-Order-Parts-Receive-Bill-Bug - Fix 2026-05-05 13:56:16 -04:00
Dave
c6af2b34b2 feature/IO-3676-Order-As-In-House-Quantity - Fix 2026-05-05 13:41:50 -04:00
Dave Richer
d51dcc0ef2 Merged in feature/IO-3672-Reynolds-Adjustments-V3 (pull request #3219)
feature/IO-3672-Reynolds-Adjustments-V3 - Expand Export logs for Reynolds
2026-05-05 17:31:36 +00:00
Dave
e6178a613d feature/IO-3672-Reynolds-Adjustments-V3 - Expand Export logs for Reynolds 2026-05-05 13:31:03 -04:00
Dave Richer
2a69115903 Merged in feature/IO-3672-Reynolds-Adjustments-V3 (pull request #3217)
feature/IO-3672-Reynolds-Adjustments-V3 - Hide DMS Posting sheet report in reynolds mode.
2026-05-04 21:08:41 +00:00
Dave
c8262da440 feature/IO-3672-Reynolds-Adjustments-V3 - Hide DMS Posting sheet report in reynolds mode. 2026-05-04 16:58:06 -04:00
Dave Richer
1f41a532e2 Merged in feature/IO-3672-Reynolds-Adjustments-V3 (pull request #3215)
feature/IO-3672-Reynolds-Adjustments-V3 - Make sure there is never a scenario where a ROGOG does not have a ROLABOR
2026-05-04 20:32:52 +00:00
Dave
32e67b14b6 feature/IO-3672-Reynolds-Adjustments-V3 - Make sure there is never a scenario where a ROGOG does not have a ROLABOR 2026-05-04 16:31:41 -04:00
Dave Richer
d901004751 Merged in feature/IO-3674-Fix-Save-And-New (pull request #3213)
feature/IO-3674-Fix-Save-And-New - Fix Save and New so state gets reset on form when starting from a new employee
2026-05-04 20:12:59 +00:00
Dave
661e019a4d feature/IO-3674-Fix-Save-And-New - Fix Save and New so state gets reset on form when starting from a new employee 2026-05-04 16:11:54 -04:00
Patrick Fic
82021c1edc Updated terraform state after documenso update. 2026-05-01 08:40:44 -07:00
Dave
a6156a70c1 feature/IO-2433-esignature - Add in Notifications 2026-04-30 18:06:32 -04:00
Dave
0014a5335d Merge branch 'master-AIO' into feature/IO-2433-esignature 2026-04-30 12:08:35 -04:00
Allan Carr
cd054fcf33 Merged in feature/IO-3650-Pagination-Corrections (pull request #3208)
IO-3650 Pagination Corrections

Approved-by: Dave Richer
2026-04-29 16:46:02 +00:00
Allan Carr
5ab54433ff Merged in feature/IO-3562-VPB-Exclude-Suspended (pull request #3209)
IO-3562 Visual Production Board Statistics - Exclude Suspended Jobs

Approved-by: Dave Richer
2026-04-29 16:45:31 +00:00
Allan Carr
62c053ed87 Merged in feature/IO-3667-Visual-Production-Title-Color (pull request #3207)
IO-3667 Visual Production Title Color

Approved-by: Dave Richer
2026-04-29 16:44:46 +00:00
Allan Carr
6242e0f309 IO-3667 - Correction for Styling
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 18:10:49 -07:00
Allan Carr
614420d7d2 IO-3562 Visual Production Board Statistics - Exclude Suspended Jobs
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 18:06:11 -07:00
Allan Carr
3113818a91 IO-3650 Pagination Corrections
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 16:44:23 -07:00
Allan Carr
92a3e57205 IO-3667 Visual Production Title Color
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 15:46:40 -07:00
Dave Richer
de6038038a Merged in hotfix/2026-04-28 (pull request #3206)
hotfix/2026-04-28 - Add Label, fix exported
2026-04-28 18:03:13 +00:00
Dave Richer
8a043767cd Merged in hotfix/2026-04-28 (pull request #3204)
hotfix/2026-04-28 - Add Label, fix exported
2026-04-28 17:52:28 +00:00
Dave
1f8836d9d8 hotfix/2026-04-28 - Add Label, fix exported 2026-04-28 13:51:46 -04:00
Patrick Fic
6ca0ebff5f Add additional translations and cleanup.
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 11:37:25 -07:00
Patrick Fic
a96a1139fa IO-2433 Added LMS document upload.
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 09:59:48 -07:00
Patrick Fic
483da283dc Fetch documenso key dynamically and clean up. Update to 2.9.1 in terraform. 2026-04-23 10:41:34 -07:00
Dave Richer
a267d65425 Merged in hotfix/2026-04-21 (pull request #3203)
Hotfix/2026 04 21
2026-04-22 16:44:49 +00:00
Dave Richer
9267e584ff Merged in hotfix/2026-04-21 (pull request #3201)
hotfix/2026-04-21 - fix Parts order comments
2026-04-22 16:43:19 +00:00
Dave
cacda3805a hotfix/2026-04-21 - fix Parts order comments 2026-04-22 12:42:49 -04:00
Dave Richer
69861af88c Merged in feature/IO-3647-Reynolds-Integration-Phase-2-Optional (pull request #3199)
feature/IO-3647-Reynolds-Integration-Phase-2-Optional - Add option to make 'Enhanced Early ROS' optional
2026-04-21 14:52:00 +00:00
Dave
d7294ebba6 feature/IO-3647-Reynolds-Integration-Phase-2-Optional - Add option to make 'Enhanced Early ROS' optional 2026-04-21 10:51:13 -04:00
Dave Richer
d9270102b1 Merged in hotfix/2026-04-21 (pull request #3197)
hotfix/2026-04-21 - Fix save dirty state on employees causing prompt, add 'save and new'
2026-04-21 14:29:33 +00:00
Dave
af757ee71e hotfix/2026-04-21 - Fix save dirty state on employees causing prompt, add 'save and new' 2026-04-21 10:28:26 -04:00
Patrick Fic
d416780e63 Merge remote-tracking branch 'origin/master-AIO' into feature/IO-2433-esignature 2026-04-20 11:38:35 -07:00
Patrick Fic
b6cbfb8e45 IO-2443 Minor clean up. 2026-04-20 11:37:19 -07:00
Patrick Fic
9c97b30e8e Missed in last commit. 2026-04-20 09:51:34 -07:00
Patrick Fic
cc48448a07 Finalize self hosted with self certificate, resolve webhook issues. 2026-04-20 09:49:48 -07:00
Dave Richer
eb666f2ca1 Merged in hotfix/2026-04-20 (pull request #3195)
hotfix/2026-04-20 - Remove item from Cost centers
2026-04-20 15:57:38 +00:00
Dave Richer
d991e32501 Merged in hotfix/2026-04-20 (pull request #3193)
hotfix/2026-04-20 - Remove item from Cost centers
2026-04-20 15:41:29 +00:00
Dave
2b8990950b hotfix/2026-04-20 - Remove item from Cost centers 2026-04-20 11:40:19 -04:00
Dave Richer
3f2e05befc Merged in release/2026-04-17 (pull request #3192)
Release/2026-04-17 into master-AIO - IO-1366, IO-3624, IO-3638
2026-04-18 02:12:37 +00:00
Dave Richer
06bfdeb449 Merged in feature/IO-3647-Reynolds-Integration-Phase-2 (pull request #3191)
feature/IO-3647-Reynolds-Integration-Phase-2 - Enhance early RO with meaningful amounts.
2026-04-13 15:00:38 +00:00
Dave Richer
1b2f9fc027 Merged in hotfix/2026-04-10 (pull request #3188)
hotfix/2026-04-10 - Fix Location Identifier in chatter-api
2026-04-10 15:42:43 +00:00
Patrick Fic
969dd8be8d Add tfvars exclusion. 2026-03-30 11:41:54 -07:00
Patrick Fic
794f64dfba Add custom document signing. 2026-03-30 11:41:24 -07:00
Patrick Fic
220b1c7968 Deployed version of Documenso. 2026-03-26 14:57:09 -07:00
Patrick Fic
7dab60e3bc Initial terraform for Documenso. 2026-03-26 09:15:00 -07:00
Patrick Fic
d4c7298334 Eisgnature Migrations, webhook handling, and clean up. 2026-03-25 15:24:14 -07:00
Patrick Fic
e17b57c705 Merge remote-tracking branch 'origin/master-AIO' into feature/IO-2433-esignature 2026-03-23 12:57:38 -07:00
Patrick Fic
4abc1a7d0f IO-2433 Resolve webhook issue on esig. 2026-03-16 16:03:37 -07:00
Patrick Fic
255d761210 IO-2433 Add esignature flag to JSR call. 2026-03-16 11:10:52 -07:00
Patrick Fic
2a5e5d2462 IO-2433 Resolve missing esig file errors from JSR. 2026-03-16 09:04:52 -07:00
Allan Carr
6ef56f97c0 IO-2433 Missing Translation
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-12 16:58:59 -07:00
Patrick Fic
97d8047a3d Update casing for esign route. 2026-03-05 15:56:13 -08:00
Patrick Fic
16220d0a27 Merge branch 'master-AIO' into feature/IO-2433-esignature 2026-03-04 15:01:25 -08:00
Patrick Fic
51fba24a3d IO-2433 Delete on cancel, improved styling. 2026-02-27 16:03:27 -08:00
Patrick Fic
52f43a600c IO-2433 Basic completion webhook, S3 upload, audit trail. 2026-02-27 15:44:23 -08:00
Patrick Fic
e25174ff97 IO-2433 Basic embedded authoring. 2026-02-27 13:15:10 -08:00
125 changed files with 18665 additions and 1117 deletions

5
.gitignore vendored
View File

@@ -149,3 +149,8 @@ docker_data
/COPILOT.md
/.github/copilot-instructions.md
/GEMINI.md
/_reference/select-component-test-plan.md
.terraform
terraform.tfvars

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@documenso/embed-react": "^0.5.1",
"@emotion/is-prop-valid": "^1.4.0",
"@fingerprintjs/fingerprintjs": "^5.1.0",
"@firebase/analytics": "^0.10.21",
@@ -2593,6 +2594,16 @@
"react": ">=16.8.0"
}
},
"node_modules/@documenso/embed-react": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@documenso/embed-react/-/embed-react-0.5.1.tgz",
"integrity": "sha512-PlkZ3vrdZVBTc0J3xfG2wtPVGmxCxWgpQ/SsdR2oBMdTwsR+rDbj9k+CeTv+M9Xi5tKbLr5Y78bS9Sb8K+ltTQ==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.59.1.tgz",

View File

@@ -15,6 +15,7 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@documenso/embed-react": "^0.5.1",
"@emotion/is-prop-valid": "^1.4.0",
"@fingerprintjs/fingerprintjs": "^5.1.0",
"@firebase/analytics": "^0.10.21",

View File

@@ -1,6 +1,6 @@
import { useSplitClient } from "@splitsoftware/splitio-react";
import { Button, Result } from "antd";
import LogRocket from "logrocket";
//import LogRocket from "logrocket";
import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
@@ -101,13 +101,13 @@ export function App({
client.setAttribute("imexshopid", bodyshop.imexshopid);
if (client.getTreatment("LogRocket_Tracking") === "on") {
console.log("LR Start");
LogRocket.init(
InstanceRenderMgr({
imex: "gvfvfw/bodyshopapp",
rome: "rome-online/rome-online"
})
);
// console.log("LR Start");
// LogRocket.init(
// InstanceRenderMgr({
// imex: "gvfvfw/bodyshopapp",
// rome: "rome-online/rome-online"
// })
// );
}
}
}, [bodyshop, client, currentUser.authorized]);

View File

@@ -509,3 +509,10 @@
pointer-events: none !important;
}
}
.esignature-embed {
width: 100%;
height: 100%;
border-width: 0;
}

View File

@@ -435,9 +435,9 @@ export function BillEnterModalLinesComponent({
rules: [{ required: true }]
}),
formInput: () => (
<Select
showSearch
style={{ minWidth: "3rem" }}
<Select
showSearch
style={{ minWidth: "3rem" }}
disabled={disabled}
tabIndex={0}
options={
@@ -461,7 +461,7 @@ export function BillEnterModalLinesComponent({
name: [field.name, "location"]
}),
formInput: () => (
<Select
<Select
disabled={disabled}
tabIndex={0}
options={bodyshop.md_parts_locations.map((loc) => ({ value: loc, label: loc }))}
@@ -495,7 +495,9 @@ export function BillEnterModalLinesComponent({
{Enhanced_Payroll.treatment === "on" ? (
<Space>
{t("joblines.fields.assigned_team", { name: employeeTeamName?.name })}
{`${jobline.mod_lb_hrs} units/${t(`joblines.fields.lbr_types.${jobline.mod_lbr_ty}`)}`}
{jobline
? `${jobline.mod_lb_hrs} units/${t(`joblines.fields.lbr_types.${jobline.mod_lbr_ty}`)}`
: null}
</Space>
) : null}
@@ -506,10 +508,7 @@ export function BillEnterModalLinesComponent({
rules={[{ required: true }]}
name={[record.name, "lbr_adjustment", "mod_lbr_ty"]}
>
<Select
allowClear
options={CiecaSelect(false, true)}
/>
<Select allowClear options={CiecaSelect(false, true)} />
</Form.Item>
{Enhanced_Payroll.treatment === "on" ? (

View File

@@ -1,4 +1,4 @@
import { Button, Col } from "antd";
import { Button, Checkbox, Col } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -49,7 +49,13 @@ export default function PBSCustomerSelector({ bodyshop, socket }) {
if (!open) return null;
const columns = [
{ title: t("jobs.fields.dms.id"), dataIndex: "ContactId", key: "ContactId" },
{ title: t("jobs.fields.dms.id"), dataIndex: "Code", key: "ContactId" },
{
title: t("jobs.fields.dms.IsARCustomer"),
dataIndex: "IsARCustomer",
key: "IsARCustomer",
render: (text, record) => <Checkbox checked={record.IsARCustomer} disabled />
},
{
title: t("jobs.fields.dms.name1"),
key: "name1",

View File

@@ -1,5 +1,5 @@
import axios from "axios";
import { Result } from "antd";
import { Result, theme } from "antd";
import * as markerjs2 from "markerjs2";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -9,6 +9,12 @@ import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selecto
import { handleUpload } from "../documents-local-upload/documents-local-upload.utility";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import {
addGreyscaleButtonToMarkerArea,
addImageHistoryUndoToMarkerArea,
applyGreyscaleToMarkerAreaImage,
setMarkerAreaImageSource
} from "./document-editor.utility";
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
@@ -24,7 +30,9 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const markerArea = useRef(null);
const imageHistory = useRef([]);
const { t } = useTranslation();
const { token } = theme.useToken();
const notification = useNotification();
const [uploading, setUploading] = useState(false);
@@ -32,6 +40,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
async (dataUrl) => {
if (uploading) return;
setUploading(true);
setLoading(true);
const blob = await b64toBlob(dataUrl);
const nameWithoutExt = filename.split(".").slice(0, -1).join(".").trim();
const parts = nameWithoutExt.split("-");
@@ -70,6 +79,23 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
[filename, jobid, notification, uploading]
);
const handleGreyscale = useCallback(() => {
if (!imgRef.current || loading || uploaded || imageLoading || !imageLoaded) return;
imageHistory.current.push(imgRef.current.src);
applyGreyscaleToMarkerAreaImage(markerArea.current, imgRef.current);
}, [imageLoaded, imageLoading, loading, uploaded]);
const undoImageEdit = useCallback(() => {
if (!imgRef.current) return;
const previousSrc = imageHistory.current.pop();
if (previousSrc) {
setMarkerAreaImageSource(markerArea.current, imgRef.current, previousSrc);
}
}, []);
useEffect(() => {
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
// create a marker.js MarkerArea
@@ -93,8 +119,10 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
markerArea.current.renderImageQuality = 1;
//markerArea.current.settings.displayMode = "inline";
markerArea.current.show();
addGreyscaleButtonToMarkerArea(markerArea.current, handleGreyscale, t("documents.labels.greyscale"));
addImageHistoryUndoToMarkerArea(markerArea.current, () => imageHistory.current.length > 0, undoImageEdit);
}
}, [triggerUpload, imageLoaded]);
}, [handleGreyscale, imageLoaded, t, triggerUpload, undoImageEdit]);
useEffect(() => {
if (!imageUrl) return;
@@ -106,6 +134,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
try {
const response = await axios.get(imageUrl, { responseType: "blob", signal: controller.signal });
const blobUrl = URL.createObjectURL(response.data);
imageHistory.current = [];
setLoadedImageUrl((prevUrl) => {
if (prevUrl) URL.revokeObjectURL(prevUrl);
return blobUrl;
@@ -142,7 +171,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
}
return (
<div>
<div style={{ background: token.colorBgBase, color: token.colorText, minHeight: "100vh" }}>
{!loading && !uploaded && loadedImageUrl && (
<img
ref={imgRef}
@@ -158,7 +187,12 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
{(loading || imageLoading || !imageLoaded) && !uploaded && (
<LoadingSpinner message={t("documents.labels.uploading")} />
)}
{uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />}
{uploaded && (
<Result
status="success"
title={<span style={{ color: token.colorText }}>{t("documents.successes.edituploaded")}</span>}
/>
)}
</div>
);
}

View File

@@ -1,15 +1,21 @@
//import "tui-image-editor/dist/tui-image-editor.css";
import axios from "axios";
import { Result } from "antd";
import { Result, theme } from "antd";
import * as markerjs2 from "markerjs2";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { handleUpload } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility.js";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import {
addGreyscaleButtonToMarkerArea,
addImageHistoryUndoToMarkerArea,
applyGreyscaleToMarkerAreaImage,
setMarkerAreaImageSource
} from "./document-editor.utility";
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
@@ -27,7 +33,9 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const markerArea = useRef(null);
const imageHistory = useRef([]);
const { t } = useTranslation();
const { token } = theme.useToken();
const notification = useNotification();
const triggerUpload = useCallback(
@@ -57,6 +65,23 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
[bodyshop, currentUser, document, notification]
);
const handleGreyscale = useCallback(() => {
if (!imgRef.current || loading || uploaded || imageLoading || !imageLoaded) return;
imageHistory.current.push(imgRef.current.src);
applyGreyscaleToMarkerAreaImage(markerArea.current, imgRef.current);
}, [imageLoaded, imageLoading, loading, uploaded]);
const undoImageEdit = useCallback(() => {
if (!imgRef.current) return;
const previousSrc = imageHistory.current.pop();
if (previousSrc) {
setMarkerAreaImageSource(markerArea.current, imgRef.current, previousSrc);
}
}, []);
useEffect(() => {
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
// create a marker.js MarkerArea
@@ -80,8 +105,10 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
markerArea.current.renderImageQuality = 1;
//markerArea.current.settings.displayMode = "inline";
markerArea.current.show();
addGreyscaleButtonToMarkerArea(markerArea.current, handleGreyscale, t("documents.labels.greyscale"));
addImageHistoryUndoToMarkerArea(markerArea.current, () => imageHistory.current.length > 0, undoImageEdit);
}
}, [triggerUpload, imageLoaded]);
}, [handleGreyscale, imageLoaded, t, triggerUpload, undoImageEdit]);
useEffect(() => {
if (!document?.id) return;
@@ -100,6 +127,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
}
);
const blobUrl = URL.createObjectURL(response.data);
imageHistory.current = [];
setImageUrl((prevUrl) => {
if (prevUrl) URL.revokeObjectURL(prevUrl);
return blobUrl;
@@ -134,7 +162,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
}
return (
<div>
<div style={{ background: token.colorBgBase, color: token.colorText, minHeight: "100vh" }}>
{!loading && !uploaded && imageUrl && (
<img
ref={imgRef}
@@ -150,7 +178,12 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
{(loading || imageLoading || !imageLoaded) && !uploaded && (
<LoadingSpinner message={t("documents.labels.uploading")} />
)}
{uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />}
{uploaded && (
<Result
status="success"
title={<span style={{ color: token.colorText }}>{t("documents.successes.edituploaded")}</span>}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,123 @@
/**
* Converts an image element to a greyscale data URL.
* @param imageElement
* @returns {string}
*/
export function convertImageElementToGreyscaleDataUrl(imageElement) {
if (!imageElement?.naturalWidth || !imageElement?.naturalHeight) {
throw new Error("Image must be loaded before it can be converted to greyscale.");
}
const canvas = document.createElement("canvas");
canvas.width = imageElement.naturalWidth;
canvas.height = imageElement.naturalHeight;
const context = canvas.getContext("2d");
context.drawImage(imageElement, 0, 0);
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
for (let i = 0; i < pixels.length; i += 4) {
const luminance = Math.round(pixels[i] * 0.299 + pixels[i + 1] * 0.587 + pixels[i + 2] * 0.114);
pixels[i] = luminance;
pixels[i + 1] = luminance;
pixels[i + 2] = luminance;
}
context.putImageData(imageData, 0, 0);
return canvas.toDataURL("image/jpeg", 1);
}
/**
* Adds a greyscale button to the marker area controls if it doesn't already exist.
* @param markerArea
* @param onGreyscale
* @param title
*/
export function addGreyscaleButtonToMarkerArea(markerArea, onGreyscale, title) {
requestAnimationFrame(() => {
const renderButton = markerArea?.coverDiv?.querySelector?.('[data-action="render"]');
if (!renderButton || markerArea.coverDiv.querySelector('[data-action="greyscale"]')) return;
const greyscaleButton = document.createElement("div");
greyscaleButton.className = renderButton.className;
greyscaleButton.innerHTML =
'<svg viewBox="0 0 24 24"><path d="M12 2a10 10 0 1 0 0 20V2zm0 2.25v15.5a7.75 7.75 0 0 1 0-15.5z"/></svg>';
greyscaleButton.setAttribute("role", "button");
greyscaleButton.setAttribute("data-action", "greyscale");
greyscaleButton.setAttribute("aria-label", title);
greyscaleButton.title = title;
greyscaleButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
onGreyscale();
});
renderButton.parentElement.insertBefore(greyscaleButton, renderButton);
});
}
/**
* Applies a greyscale filter to the image in the marker area and updates the image source.
* @param markerArea
* @param imageElement
* @returns {string}
*/
export function applyGreyscaleToMarkerAreaImage(markerArea, imageElement) {
const dataUrl = convertImageElementToGreyscaleDataUrl(imageElement);
setMarkerAreaImageSource(markerArea, imageElement, dataUrl);
return dataUrl;
}
/**
* Sets the image source for the marker area and updates the editing target if it's an image element.
* @param markerArea
* @param imageElement
* @param src
*/
export function setMarkerAreaImageSource(markerArea, imageElement, src) {
imageElement.src = src;
if (markerArea?.editingTarget instanceof HTMLImageElement) {
markerArea.editingTarget.src = src;
}
}
/**
* Adds undo functionality for image edits to the marker area by tracking the state before and after undo actions.
* @param markerArea
* @param canUndoImage
* @param undoImage
*/
export function addImageHistoryUndoToMarkerArea(markerArea, canUndoImage, undoImage) {
requestAnimationFrame(() => {
const undoButton = markerArea?.coverDiv?.querySelector?.('[data-action="undo"]');
if (!undoButton || undoButton.dataset.imageHistoryUndo === "true") return;
let markerStateBeforeUndo = null;
undoButton.dataset.imageHistoryUndo = "true";
undoButton.addEventListener(
"click",
() => {
markerStateBeforeUndo = JSON.stringify(markerArea.getState(true));
},
true
);
undoButton.addEventListener("click", () => {
const markerStateAfterUndo = JSON.stringify(markerArea.getState(true));
if (markerStateBeforeUndo === markerStateAfterUndo && canUndoImage()) {
undoImage();
}
markerStateBeforeUndo = null;
});
});
}

View File

@@ -4,6 +4,7 @@ import i18n from "i18next";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { INSERT_NEW_DOCUMENT } from "../../graphql/documents.queries";
import { axiosAuthInterceptorId } from "../../utils/CleanAxios";
import { replaceAccents } from "../../utils/replaceAccents.js";
import client from "../../utils/GraphQLClient";
//Context: currentUserEmail, bodyshop, jobid, invoiceid
@@ -144,32 +145,3 @@ export const uploadToS3 = async (
if (onError) onError(JSON.stringify(error.message));
}
};
function replaceAccents(str) {
// Verifies if the String has accents and replace them
if (str.search(/[\xC0-\xFF]/g) > -1) {
str = str
.replace(/[\xC0-\xC5]/g, "A")
.replace(/[\xC6]/g, "AE")
.replace(/[\xC7]/g, "C")
.replace(/[\xC8-\xCB]/g, "E")
.replace(/[\xCC-\xCF]/g, "I")
.replace(/[\xD0]/g, "D")
.replace(/[\xD1]/g, "N")
.replace(/[\xD2-\xD6\xD8]/g, "O")
.replace(/[\xD9-\xDC]/g, "U")
.replace(/[\xDD]/g, "Y")
.replace(/[\xDE]/g, "P")
.replace(/[\xE0-\xE5]/g, "a")
.replace(/[\xE6]/g, "ae")
.replace(/[\xE7]/g, "c")
.replace(/[\xE8-\xEB]/g, "e")
.replace(/[\xEC-\xEF]/g, "i")
.replace(/[\xF1]/g, "n")
.replace(/[\xF2-\xF6\xF8]/g, "o")
.replace(/[\xF9-\xFC]/g, "u")
.replace(/[\xFE]/g, "p")
.replace(/[\xFD\xFF]/g, "y");
}
return str;
}

View File

@@ -0,0 +1,92 @@
import { UploadOutlined } from "@ant-design/icons";
import { Button, Upload } from "antd";
import axios from "axios";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
setEsignatureContext: (context) =>
dispatch(
setModalContext({
context,
modal: "esignature"
})
)
});
export function EsignatureCustomDocument({ bodyshop, jobId, setEsignatureContext }) {
const [loading, setLoading] = useState(false);
const notification = useNotification();
const { t } = useTranslation();
if (!hasDocumensoApiKey(bodyshop)) {
return null;
}
const uploadCustomDocument = async ({ file, onError, onSuccess }) => {
const formData = new FormData();
formData.append("document", file);
formData.append("jobid", jobId);
formData.append("bodyshop", JSON.stringify(bodyshop));
setLoading(true);
try {
const {
data: { token, documentId, envelopeId }
} = await axios.post("/esign/new-custom", formData, {
headers: {
"Content-Type": "multipart/form-data"
}
});
setEsignatureContext({ context: { token, documentId, envelopeId, jobid: jobId } });
onSuccess?.({ token, documentId, envelopeId });
} catch (error) {
notification.error({
title: t("esignature.errors.upload_title"),
description: error?.response?.data?.error || error?.response?.data?.message || error.message
});
onError?.(error);
} finally {
setLoading(false);
}
};
return (
<Upload
accept="application/pdf,.pdf"
beforeUpload={(file) => {
if (file.type === "application/pdf" || file.name?.toLowerCase().endsWith(".pdf")) {
return true;
}
notification.error({
title: t("esignature.errors.upload_title"),
description: t("esignature.errors.pdf_only")
});
return Upload.LIST_IGNORE;
}}
customRequest={uploadCustomDocument}
maxCount={1}
showUploadList={false}
multiple={false}
>
<Button icon={<UploadOutlined />} loading={loading}>
{t("esignature.actions.upload_document")}
</Button>
</Upload>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(EsignatureCustomDocument);

View File

@@ -0,0 +1,101 @@
import { EmbedUpdateDocumentV1 } from "@documenso/embed-react";
import { Modal, notification, Result } from "antd";
import axios from "axios";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { toggleModalVisible } from "../../redux/modals/modals.actions";
import { selectEsignature } from "../../redux/modals/modals.selectors";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { useState } from "react";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
const mapStateToProps = createStructuredSelector({
esignatureModal: selectEsignature,
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
});
const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("esignature"))
});
export function EsignatureModalContainer({ esignatureModal, toggleModalVisible, bodyshop, currentUser }) {
const { t } = useTranslation();
const { open, context } = esignatureModal;
const { token, envelopeId, documentId, jobid } = context;
const [distributing, setDistributing] = useState(false);
if (!hasDocumensoApiKey(bodyshop)) {
return null;
}
return (
<Modal
open={open}
title={InstanceRenderManager({
imex: t("jobs.labels.esignature_imex"),
rome: t("jobs.labels.esignature_rome")
})}
onOk={async () => {
try {
setDistributing(true);
await axios.post("/esign/distribute", {
documentId,
envelopeId,
jobid,
bodyshopid: bodyshop.id
});
toggleModalVisible();
} catch (error) {
notification.error({
message: t("esignature.distribute_error"),
description: error?.response?.data?.message || error.message
});
}
setDistributing(false);
}}
onCancel={async () => {
try {
await axios.post("/esign/delete", {
documentId,
envelopeId,
bodyshopid: bodyshop.id
});
toggleModalVisible();
} catch (error) {
notification.error({
message: t("esignature.cancel_error"),
description: error?.response?.data?.message || error.message
});
}
}}
okButtonProps={{ loading: distributing }}
okText={t("esignature.actions.distribute")}
destroyOnHidden
width={"80%"}
>
<div style={{ height: "80vh", width: "100%" }}>
{token ? (
<EmbedUpdateDocumentV1
presignToken={token}
host="https://sign.imex.online"
documentId={documentId}
externalId={`${jobid}|${currentUser?.email}`}
className="esignature-embed"
onDocumentUpdated={(data) => {
console.log("Document updated:", data);
}}
/>
) : (
<Result status="warning" title={t("esignature.errors.no_token")} />
)}
</div>
</Modal>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(EsignatureModalContainer);

View File

@@ -1,6 +1,6 @@
import { SyncOutlined } from "@ant-design/icons";
import { useQuery } from "@apollo/client/react";
import { Button, Card, Col, Row, Tag } from "antd";
import { Button, Card, Checkbox, Col, Row, Space, Tag } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
@@ -12,6 +12,9 @@ import { DateTimeFormatter } from "../../utils/DateFormatter";
import BlurWrapperComponent from "../feature-wrapper/blur-wrapper.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
import axios from "axios";
import { useNotification } from "../../contexts/Notifications/notificationContext";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -23,6 +26,8 @@ export default connect(mapStateToProps, mapDispatchToProps)(JobAuditTrail);
export function JobAuditTrail({ bodyshop, jobId }) {
const { t } = useTranslation();
const notification = useNotification();
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
const { loading, data, refetch } = useQuery(QUERY_AUDIT_TRAIL, {
variables: { jobid: jobId },
skip: !jobId,
@@ -53,6 +58,145 @@ export function JobAuditTrail({ bodyshop, jobId }) {
)
}
];
const esigColumns = [
{
title: t("esignature.fields.created_at"),
dataIndex: "created_at",
key: "created_at",
render: (text) => <DateTimeFormatter>{text}</DateTimeFormatter>
},
{
title: t("esignature.fields.updated_at"),
dataIndex: "updated_at",
key: "updated_at",
render: (text) => <DateTimeFormatter>{text}</DateTimeFormatter>
},
{
title: t("esignature.fields.title"),
dataIndex: "title",
key: "title",
render: (text) => (
<BlurWrapperComponent featureName="audit" bypass>
<div>{text}</div>
</BlurWrapperComponent>
)
},
{
title: t("esignature.fields.external_document_id"),
dataIndex: "external_document_id",
key: "external_document_id",
render: (text) => (
<BlurWrapperComponent featureName="audit" bypass>
<div>{text}</div>
</BlurWrapperComponent>
)
},
{
title: t("esignature.fields.status"),
dataIndex: "status",
key: "status",
render: (text) => (
<BlurWrapperComponent featureName="audit" bypass>
<div>{text}</div>
</BlurWrapperComponent>
)
},
{
title: t("esignature.fields.opened"),
dataIndex: "opened",
key: "opened",
render: (text) => <Checkbox checked={text} disabled />
},
{
title: t("esignature.fields.rejected"),
dataIndex: "rejected",
key: "rejected",
render: (text) => <Checkbox checked={text} disabled />
},
{
title: t("esignature.fields.completed"),
dataIndex: "completed",
key: "completed",
render: (text) => <Checkbox checked={text} disabled />
},
{
title: t("esignature.fields.completed_at"),
dataIndex: "completed_at",
key: "completed_at",
render: (text) => <DateTimeFormatter>{text}</DateTimeFormatter>
},
{
title: t("general.labels.actions"),
dataIndex: "actions",
key: "actions",
render: (_text, record) => (
<Space wrap>
<Button
disabled={record.completed_at !== null || record.status === "REJECTED"}
onClick={async () => {
logImEXEvent("job_esig_delete", {});
try {
await axios.post("/esign/delete", {
documentId: record.external_document_id,
bodyshopid: bodyshop.id
});
refetch();
} catch (error) {
console.error("Error deleting document:", error?.response?.data || error.message);
notification.error({
message: t("esignature.delete_error"),
description: error?.response?.data?.error || error.message
});
}
}}
>
{t("esignature.actions.delete")}
</Button>
<Button
onClick={async () => {
logImEXEvent("job_esig_redistribute", {});
try {
await axios.post("/esign/redistribute", {
documentId: record.external_document_id,
bodyshopid: bodyshop.id
});
//Pop the success notification. Possible audit requery required.
} catch (error) {
console.error("Error viewing document:", error?.response?.data || error.message);
notification.error({
message: t("esignature.view_error"),
description: error?.response?.data?.message || error.message
});
}
}}
>
{t("esignature.actions.redistribute")}
</Button>
<Button
onClick={async () => {
logImEXEvent("job_esig_view", {});
try {
const response = await axios.post("/esign/view", {
documentId: record.external_document_id,
bodyshopid: bodyshop.id
});
window.open(response.data?.document?.downloadUrl, "_blank");
} catch (error) {
console.error("Error viewing document:", error?.response?.data || error.message);
notification.error({
message: t("esignature.view_error"),
description: error?.response?.data?.message || error.message
});
}
}}
>
{t("esignature.actions.view")}
</Button>
</Space>
)
}
];
const emailColumns = [
{
title: t("audit.fields.created"),
@@ -184,6 +328,20 @@ export function JobAuditTrail({ bodyshop, jobId }) {
/>
</Card>
</Col>
{esignatureEnabled && (
<Col span={24}>
<Card title={t("jobs.labels.esignatures")}>
<ResponsiveTable
loading={loading}
columns={esigColumns}
mobileColumnKeys={["title", "status"]}
rowKey="id"
scroll={{ x: true }}
dataSource={data ? data.esignature_documents : []}
/>
</Card>
</Col>
)}
</Row>
);
}

View File

@@ -44,6 +44,7 @@ import JoblineTeamAssignment from "../job-line-team-assignment/job-line-team-ass
import JobSendPartPriceChangeComponent from "../job-send-parts-price-change/job-send-parts-price-change.component";
import PartsOrderDrawer from "../parts-order-list-table/parts-order-list-table-drawer.component";
import PartsOrderModalContainer from "../parts-order-modal/parts-order-modal.container";
import { buildInHouseBillLines } from "./job-lines.in-house-bill-lines.utils";
import JobLinesExpander from "./job-lines-expander.component";
import JobLinesPartPriceChange from "./job-lines-part-price-change.component";
import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component";
@@ -595,16 +596,7 @@ export function JobLinesComponent({
isinhouse: true,
date: dayjs(),
total: 0,
billlines: selectedLines.map((p) => ({
joblineid: p.id,
actual_price: p.act_price,
actual_cost: 0,
line_desc: p.line_desc,
line_remarks: p.line_remarks,
part_type: p.part_type,
quantity: p.quantity || 1,
applicable_taxes: { local: false, state: false, federal: false }
}))
billlines: buildInHouseBillLines(selectedLines)
}
}
});

View File

@@ -0,0 +1,11 @@
export const buildInHouseBillLines = (lines) =>
lines.map((line) => ({
joblineid: line.id,
actual_price: line.act_price,
actual_cost: 0,
line_desc: line.line_desc,
line_remarks: line.line_remarks,
part_type: line.part_type,
quantity: line.part_qty ?? line.quantity ?? 1,
applicable_taxes: { local: false, state: false, federal: false }
}));

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { buildInHouseBillLines } from "./job-lines.in-house-bill-lines.utils";
describe("buildInHouseBillLines", () => {
it("carries job line part quantity into the in-house bill line", () => {
const billLines = buildInHouseBillLines([
{
id: "job-line-1",
act_price: 125,
line_desc: "Door shell",
line_remarks: "Left",
part_type: "PAA",
part_qty: 3
}
]);
expect(billLines[0]).toMatchObject({
joblineid: "job-line-1",
actual_price: 125,
actual_cost: 0,
line_desc: "Door shell",
line_remarks: "Left",
part_type: "PAA",
quantity: 3,
applicable_taxes: { local: false, state: false, federal: false }
});
});
it("falls back to legacy quantity and then one when part quantity is absent", () => {
expect(buildInHouseBillLines([{ id: "legacy", quantity: 2 }])[0].quantity).toBe(2);
expect(buildInHouseBillLines([{ id: "missing" }])[0].quantity).toBe(1);
});
});

View File

@@ -67,22 +67,25 @@ export function JobLinesUpsertModalComponent({ bodyshop, open, jobLine, handleCa
</LayoutFormRow>
<LayoutFormRow grow>
<Form.Item label={t("joblines.fields.mod_lbr_ty")} name="mod_lbr_ty">
<Select allowClear options={[
{ value: "LAA", label: t("joblines.fields.lbr_types.LAA") },
{ value: "LAB", label: t("joblines.fields.lbr_types.LAB") },
{ value: "LAD", label: t("joblines.fields.lbr_types.LAD") },
{ value: "LAE", label: t("joblines.fields.lbr_types.LAE") },
{ value: "LAF", label: t("joblines.fields.lbr_types.LAF") },
{ value: "LAG", label: t("joblines.fields.lbr_types.LAG") },
{ value: "LAM", label: t("joblines.fields.lbr_types.LAM") },
{ value: "LAR", label: t("joblines.fields.lbr_types.LAR") },
{ value: "LAS", label: t("joblines.fields.lbr_types.LAS") },
{ value: "LAU", label: t("joblines.fields.lbr_types.LAU") },
{ value: "LA1", label: t("joblines.fields.lbr_types.LA1") },
{ value: "LA2", label: t("joblines.fields.lbr_types.LA2") },
{ value: "LA3", label: t("joblines.fields.lbr_types.LA3") },
{ value: "LA4", label: t("joblines.fields.lbr_types.LA4") }
]} />
<Select
allowClear
options={[
{ value: "LAA", label: t("joblines.fields.lbr_types.LAA") },
{ value: "LAB", label: t("joblines.fields.lbr_types.LAB") },
{ value: "LAD", label: t("joblines.fields.lbr_types.LAD") },
{ value: "LAE", label: t("joblines.fields.lbr_types.LAE") },
{ value: "LAF", label: t("joblines.fields.lbr_types.LAF") },
{ value: "LAG", label: t("joblines.fields.lbr_types.LAG") },
{ value: "LAM", label: t("joblines.fields.lbr_types.LAM") },
{ value: "LAR", label: t("joblines.fields.lbr_types.LAR") },
{ value: "LAS", label: t("joblines.fields.lbr_types.LAS") },
{ value: "LAU", label: t("joblines.fields.lbr_types.LAU") },
{ value: "LA1", label: t("joblines.fields.lbr_types.LA1") },
{ value: "LA2", label: t("joblines.fields.lbr_types.LA2") },
{ value: "LA3", label: t("joblines.fields.lbr_types.LA3") },
{ value: "LA4", label: t("joblines.fields.lbr_types.LA4") }
]}
/>
</Form.Item>
<Form.Item label={t("joblines.fields.op_code_desc")} name="op_code_desc">
<Input />
@@ -128,21 +131,27 @@ export function JobLinesUpsertModalComponent({ bodyshop, open, jobLine, handleCa
</LayoutFormRow>
<LayoutFormRow>
<Form.Item label={t("joblines.fields.part_type")} name="part_type">
<Select allowClear options={[
{ value: "PAA", label: t("joblines.fields.part_types.PAA") },
{ value: "PAC", label: t("joblines.fields.part_types.PAC") },
{ value: "PAE", label: t("joblines.fields.part_types.PAE") },
{ value: "PAL", label: t("joblines.fields.part_types.PAL") },
{ value: "PAM", label: t("joblines.fields.part_types.PAM") },
{ value: "PAN", label: t("joblines.fields.part_types.PAN") },
{ value: "PAO", label: t("joblines.fields.part_types.PAO") },
{ value: "PAR", label: t("joblines.fields.part_types.PAR") },
{ value: "PAS", label: t("joblines.fields.part_types.PAS") }
]} />
<Select
allowClear
options={[
{ value: "PAA", label: t("joblines.fields.part_types.PAA") },
{ value: "PAC", label: t("joblines.fields.part_types.PAC") },
{ value: "PAE", label: t("joblines.fields.part_types.PAE") },
{ value: "PAL", label: t("joblines.fields.part_types.PAL") },
{ value: "PAM", label: t("joblines.fields.part_types.PAM") },
{ value: "PAN", label: t("joblines.fields.part_types.PAN") },
{ value: "PAO", label: t("joblines.fields.part_types.PAO") },
{ value: "PAR", label: t("joblines.fields.part_types.PAR") },
{ value: "PAS", label: t("joblines.fields.part_types.PAS") }
]}
/>
</Form.Item>
<Form.Item label={t("joblines.fields.oem_partno")} name="oem_partno">
<Input />
</Form.Item>
<Form.Item label={t("joblines.fields.alt_partno")} name="alt_partno">
<Input />
</Form.Item>
<Form.Item
label={t("joblines.fields.part_qty")}
name="part_qty"

View File

@@ -224,14 +224,10 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
</>
)}
<Form.Item
name={["ins_co_nm"]}
label={t("jobs.fields.ins_co_nm")}
rules={[{ required: true }]}
>
<Form.Item name={["ins_co_nm"]} label={t("jobs.fields.ins_co_nm")} rules={[{ required: true }]}>
<Select
showSearch={{
optionFilterProp:'label'
optionFilterProp: "label"
}}
options={insuranceOptions}
/>
@@ -250,7 +246,7 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
label={t("jobs.fields.referralsource")}
rules={[{ required: bodyshop.enforce_referral }]}
>
<Select options={referralOptions} />
<Select showSearch={{ optionFilterProp: "label" }} options={referralOptions} />
</Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
@@ -272,19 +268,21 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
>
<Select
showSearch={{
optionFilterProp: 'label',
filterOption: (input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
optionFilterProp: "label",
filterOption: (input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}}
style={{ width: 200 }}
options={csrOptions}
/>
</Form.Item>
)}
{bodyshop.enforce_conversion_category && (
<Form.Item name="category" label={t("jobs.fields.category")} rules={[{ required: bodyshop.enforce_conversion_category }]}>
<Form.Item
name="category"
label={t("jobs.fields.category")}
rules={[{ required: bodyshop.enforce_conversion_category }]}
>
<Select allowClear options={categoryOptions} />
</Form.Item>
)}

View File

@@ -193,6 +193,9 @@ export function JobsCreateJobsInfo({ bodyshop, form, selected }) {
</Form.Item>
<Form.Item label={t("jobs.fields.referralsource")} name="referral_source">
<Select
showSearch={{
optionFilterProp: "label"
}}
options={bodyshop.md_referral_sources.map((s) => ({
value: s,
label: s

View File

@@ -43,19 +43,25 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
<Input disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.ded_status")} name="ded_status">
<Select disabled={jobRO} options={[
{ value: "W", label: t("jobs.labels.deductible.waived") },
{ value: "Y", label: t("jobs.labels.deductible.stands") }
]} />
<Select
disabled={jobRO}
options={[
{ value: "W", label: t("jobs.labels.deductible.waived") },
{ value: "Y", label: t("jobs.labels.deductible.stands") }
]}
/>
</Form.Item>
<Form.Item label={t("jobs.fields.ded_amt")} name="ded_amt">
<CurrencyInput disabled={jobRO} min={0} />
</Form.Item>
<Form.Item label={t("jobs.fields.ded_note")} name="ded_note">
<Select disabled={jobRO} options={bodyshop.md_ded_notes.map((n) => ({
value: n,
label: n
}))} />
<Select
disabled={jobRO}
options={bodyshop.md_ded_notes.map((n) => ({
value: n,
label: n
}))}
/>
</Form.Item>
<Form.Item label={t("jobs.fields.policy_no")} name="policy_no">
<Input disabled={jobRO} />
@@ -65,10 +71,14 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
</Form.Item>
<Form.Item label={t("jobs.fields.ins_co_nm")} name="ins_co_nm">
<Select disabled={jobRO} onChange={handleInsCoChange} options={bodyshop.md_ins_cos.map((s) => ({
value: s.name,
label: s.name
}))} />
<Select
disabled={jobRO}
onChange={handleInsCoChange}
options={bodyshop.md_ins_cos.map((s) => ({
value: s.name,
label: s.name
}))}
/>
</Form.Item>
<Form.Item label={t("jobs.fields.ins_addr1")} name="ins_addr1">
<Input disabled={jobRO} />
@@ -119,19 +129,30 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
}
]}
>
<Select disabled={jobRO} allowClear options={bodyshop.md_referral_sources.map((s) => ({
value: s,
label: s
}))} />
<Select
disabled={jobRO}
allowClear
showSearch={{
optionFilterProp: "label"
}}
options={bodyshop.md_referral_sources.map((s) => ({
value: s,
label: s
}))}
/>
</Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
<Input disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.alt_transport")} name="alt_transport">
<Select disabled={jobRO} allowClear options={bodyshop.appt_alt_transport.map((s) => ({
value: s,
label: s
}))} />
<Select
disabled={jobRO}
allowClear
options={bodyshop.appt_alt_transport.map((s) => ({
value: s,
label: s
}))}
/>
</Form.Item>
</FormRow>
<Row gutter={[16, 16]}>
@@ -233,10 +254,14 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
</FormRow>
<FormRow header={t("jobs.forms.other")}>
<Form.Item label={t("jobs.fields.category")} name="category">
<Select disabled={jobRO} allowClear options={bodyshop.md_categories.map((s) => ({
value: s,
label: s
}))} />
<Select
disabled={jobRO}
allowClear
options={bodyshop.md_categories.map((s) => ({
value: s,
label: s
}))}
/>
</Form.Item>
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
<Input disabled={jobRO} />

View File

@@ -3,7 +3,7 @@ import { Button, Card, Col, Row, Space } from "antd";
import axios from "axios";
import i18n from "i18next";
import { isFunction } from "lodash";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Lightbox from "react-image-lightbox";
import "react-image-lightbox/style.css";
@@ -12,12 +12,12 @@ import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import DocumentsUploadImgproxyComponent from "../documents-upload-imgproxy/documents-upload-imgproxy.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component";
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
import JobsDocumentsDownloadButton from "./jobs-document-imgproxy-gallery.download.component";
import JobsDocumentsGalleryReassign from "./jobs-document-imgproxy-gallery.reassign.component";
import JobsDocumentsDeleteButton from "./jobs-documents-imgproxy-gallery.delete.component";
import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-imgproxy-gallery.selectall.component";
import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -38,6 +38,9 @@ function JobsDocumentsImgproxyComponent({
const [galleryImages, setGalleryImages] = useState({ images: [], other: [] });
const { t } = useTranslation();
const [modalState, setModalState] = useState({ open: false, index: 0 });
const [previewUrls, setPreviewUrls] = useState({});
const [previewError, setPreviewError] = useState(null);
const previewUrlsRef = useRef({});
const fetchThumbnails = useCallback(() => {
fetchImgproxyThumbnails({ setStateCallback: setGalleryImages, jobId, billId });
@@ -49,8 +52,86 @@ function JobsDocumentsImgproxyComponent({
}
}, [data, fetchThumbnails]);
useEffect(() => {
return () => {
Object.values(previewUrlsRef.current).forEach(URL.revokeObjectURL);
};
}, []);
const selectedImage = modalState.open ? galleryImages.images[modalState.index] : null;
useEffect(() => {
if (!modalState.open || !selectedImage?.id) return;
if (previewUrlsRef.current[selectedImage.id]) {
setPreviewError(null);
return;
}
const controller = new AbortController();
async function loadPreviewImage() {
setPreviewError(null);
try {
const response = await axios.post(
"/media/imgproxy/original",
{ documentId: selectedImage.id },
{
responseType: "blob",
signal: controller.signal
}
);
const blobUrl = URL.createObjectURL(response.data);
previewUrlsRef.current = {
...previewUrlsRef.current,
[selectedImage.id]: blobUrl
};
setPreviewUrls(previewUrlsRef.current);
} catch (error) {
if (axios.isCancel?.(error) || error.name === "CanceledError") return;
console.error("Failed to fetch original image blob", error);
setPreviewError(error);
}
}
loadPreviewImage();
return () => {
controller.abort();
};
}, [modalState.open, selectedImage?.id]);
useEffect(() => {
if (modalState.open && !selectedImage) {
setModalState({ open: false, index: 0 });
}
}, [modalState.open, selectedImage]);
const openEditorForImage = useCallback((image) => {
if (!image?.id) return;
const newWindow = window.open(
`${window.location.protocol}//${window.location.host}/edit?documentId=${image.id}`,
"_blank",
"noopener,noreferrer"
);
if (newWindow) newWindow.opener = null;
}, []);
const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" });
const hasMobileAccess = HasFeatureAccess({ bodyshop, featureName: "mobile" });
const previewSrc = selectedImage ? previewUrls[selectedImage.id] : null;
const getLightboxImageSrc = useCallback(
(index) => {
const image = galleryImages.images[index];
return image ? previewUrls[image.id] || image.src : undefined;
},
[galleryImages.images, previewUrls]
);
return (
<div>
<Row gutter={[16, 16]}>
@@ -147,30 +228,33 @@ function JobsDocumentsImgproxyComponent({
/>
</Card>
</Col>
{modalState.open && (
{modalState.open && selectedImage && (
<Lightbox
toolbarButtons={[
<EditFilled
key="edit"
onClick={() => {
const newWindow = window.open(
`${window.location.protocol}//${window.location.host}/edit?documentId=${
galleryImages.images[modalState.index].id
}`,
"_blank",
"noopener,noreferrer"
);
if (newWindow) newWindow.opener = null;
openEditorForImage(selectedImage);
}}
/>
]}
mainSrc={galleryImages.images[modalState.index].fullsize}
nextSrc={galleryImages.images[(modalState.index + 1) % galleryImages.images.length].fullsize}
prevSrc={
imageLoadErrorMessage={previewError ? t("general.errors.notfound") : undefined}
mainSrc={previewSrc || selectedImage.src}
mainSrcThumbnail={selectedImage.src}
nextSrc={getLightboxImageSrc((modalState.index + 1) % galleryImages.images.length)}
nextSrcThumbnail={galleryImages.images[(modalState.index + 1) % galleryImages.images.length]?.src}
prevSrc={getLightboxImageSrc(
(modalState.index + galleryImages.images.length - 1) % galleryImages.images.length
)}
prevSrcThumbnail={
galleryImages.images[(modalState.index + galleryImages.images.length - 1) % galleryImages.images.length]
.fullsize
?.src
}
onCloseRequest={() => setModalState({ open: false, index: 0 })}
reactModalProps={{ ariaHideApp: false }}
onCloseRequest={() => {
setModalState({ open: false, index: 0 });
setPreviewError(null);
}}
onMovePrevRequest={() =>
setModalState({
...modalState,

View File

@@ -1,4 +1,3 @@
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
import { Checkbox, Form } from "antd";
import { useTranslation } from "react-i18next";
import PropTypes from "prop-types";
@@ -9,18 +8,18 @@ import PropTypes from "prop-types";
* @param form
* @param disabled
* @param onHeaderChange
* @param scenarioKeys
* @returns {JSX.Element}
* @constructor
*/
const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange }) => {
const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange, scenarioKeys }) => {
const { t } = useTranslation();
// Subscribe to all form values so that this component re-renders on changes.
const formValues = Form.useWatch([], form) || {};
// Determine if all scenarios for this channel are checked.
const allChecked =
notificationScenarios.length > 0 && notificationScenarios.every((scenario) => formValues[scenario]?.[channel]);
const allChecked = scenarioKeys.length > 0 && scenarioKeys.every((scenario) => formValues[scenario]?.[channel]);
const onChange = (e) => {
const checked = e.target.checked;
@@ -28,7 +27,7 @@ const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange
const currentValues = form.getFieldsValue();
// Update each scenario for this channel.
const newValues = { ...currentValues };
notificationScenarios.forEach((scenario) => {
scenarioKeys.forEach((scenario) => {
newValues[scenario] = { ...newValues[scenario], [channel]: checked };
});
// Update form values.
@@ -50,7 +49,8 @@ ColumnHeaderCheckbox.propTypes = {
channel: PropTypes.oneOf(["app", "email", "fcm"]).isRequired,
form: PropTypes.object.isRequired,
disabled: PropTypes.bool,
onHeaderChange: PropTypes.func
onHeaderChange: PropTypes.func,
scenarioKeys: PropTypes.arrayOf(PropTypes.string).isRequired
};
export default ColumnHeaderCheckbox;

View File

@@ -12,12 +12,13 @@ import {
UPDATE_NOTIFICATION_SETTINGS,
UPDATE_NOTIFICATIONS_AUTOADD
} from "../../graphql/user.queries.js";
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
import { getNotificationScenarios, notificationScenarioDefaults } from "../../utils/jobNotificationScenarios.js";
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
import PropTypes from "prop-types";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
/**
* Notifications Settings Form
@@ -35,6 +36,7 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
const [initialAutoAdd, setInitialAutoAdd] = useState(false);
const notification = useNotification();
const isEmployee = useIsEmployee(bodyshop, currentUser);
const notificationScenarios = getNotificationScenarios({ includeEsign: hasDocumensoApiKey(bodyshop) });
// Fetch notification settings and notifications_autoadd
const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, {
@@ -55,7 +57,8 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
// Ensure each scenario has an object with { app, email, fcm }
const formattedValues = notificationScenarios.reduce((acc, scenario) => {
acc[scenario] = settings[scenario] ?? { app: false, email: false, fcm: false };
acc[scenario] = settings[scenario] ??
notificationScenarioDefaults[scenario] ?? { app: false, email: false, fcm: false };
return acc;
}, {});
@@ -65,7 +68,7 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
setInitialAutoAdd(autoAdd);
setIsDirty(false); // Reset dirty state when new data loads
}
}, [data, form]);
}, [data, form, notificationScenarios]);
// Handle toggle of notifications_autoadd
const handleAutoAddToggle = async (checked) => {
@@ -136,7 +139,14 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
width: "80%"
},
{
title: <ColumnHeaderCheckbox channel="app" form={form} onHeaderChange={() => setIsDirty(true)} />,
title: (
<ColumnHeaderCheckbox
channel="app"
form={form}
onHeaderChange={() => setIsDirty(true)}
scenarioKeys={notificationScenarios}
/>
),
dataIndex: "app",
key: "app",
align: "center",
@@ -147,7 +157,14 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
)
},
{
title: <ColumnHeaderCheckbox channel="email" form={form} onHeaderChange={() => setIsDirty(true)} />,
title: (
<ColumnHeaderCheckbox
channel="email"
form={form}
onHeaderChange={() => setIsDirty(true)}
scenarioKeys={notificationScenarios}
/>
),
dataIndex: "email",
key: "email",
align: "center",
@@ -162,7 +179,14 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
// Currently disabled for prod
if (!import.meta.env.PROD) {
columns.push({
title: <ColumnHeaderCheckbox channel="fcm" form={form} onHeaderChange={() => setIsDirty(true)} />,
title: (
<ColumnHeaderCheckbox
channel="fcm"
form={form}
onHeaderChange={() => setIsDirty(true)}
scenarioKeys={notificationScenarios}
/>
),
dataIndex: "fcm",
key: "fcm",
align: "center",

View File

@@ -11,12 +11,13 @@ import ResponsiveTable from "../responsive-table/responsive-table.component";
export default function OwnersListComponent({ loading, owners, total, refetch }) {
const search = queryString.parse(useLocation().search);
const {
page
// sortcolumn, sortorder
} = search;
const { page, pageSize } = search;
const history = useNavigate();
const currentPage = Number.parseInt(page || "1", 10);
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
const [state, setState] = useState({
sortedInfo: {},
filteredInfo: { text: "" }
@@ -71,10 +72,14 @@ export default function OwnersListComponent({ loading, owners, total, refetch })
];
const handleTableChange = (pagination, filters, sorter) => {
const nextPageSize = pagination?.pageSize || currentPageSize;
const pageSizeChanged = nextPageSize !== currentPageSize;
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
const updatedSearch = {
...search,
page: pagination.current,
pageSize: nextPageSize,
page: pageSizeChanged ? 1 : pagination.current,
sortcolumn: sorter.columnKey,
sortorder: sorter.order
};
@@ -119,7 +124,7 @@ export default function OwnersListComponent({ loading, owners, total, refetch })
>
<ResponsiveTable
loading={loading}
pagination={{ placement: "top", pageSize: pageLimit, current: parseInt(page || 1, 10), total: total }}
pagination={{ placement: "top", pageSize: currentPageSize, current: currentPage, showSizeChanger: true, total: total }}
columns={columns}
mobileColumnKeys={["name", "ownr_ph1", "ownr_ph2"]}
rowKey="id"

View File

@@ -8,14 +8,19 @@ import { pageLimit } from "../../utils/config";
export default function OwnersListContainer() {
const searchParams = queryString.parse(useLocation().search);
const { page, sortcolumn, sortorder, search } = searchParams;
const { page, sortcolumn, sortorder, search, pageSize } = searchParams;
const currentPage = Number.parseInt(page || "1", 10);
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
const { loading, error, data, refetch } = useQuery(QUERY_ALL_OWNERS_PAGINATED, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
variables: {
search: search || "",
offset: page ? (page - 1) * pageLimit : 0,
limit: pageLimit,
offset: (currentPage - 1) * currentPageSize,
limit: currentPageSize,
order: [
{
[sortcolumn || "created_at"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc"

View File

@@ -176,6 +176,9 @@ export function PartsOrderModalComponent({
>
<Input />
</Form.Item>
<Form.Item key={`${index}job_line_id`} name={[field.name, "job_line_id"]} hidden>
<Input type="hidden" />
</Form.Item>
<Form.Item
label={t("parts_orders.fields.line_remarks")}
key={`${index}line_remarks`}

View File

@@ -23,6 +23,7 @@ import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import _ from "lodash";
import { UPDATE_JOB } from "../../graphql/jobs.queries";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { buildSubmittedPartsOrderLines, getSubmittedPartsOrderJobLineIds } from "./parts-order-modal.utils.js";
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
@@ -82,15 +83,10 @@ export function PartsOrderModalContainer({
// Force job_line_id from context so it never gets dropped by AntD form submission behavior.
const submittedLines = values?.parts_order_lines?.data ?? [];
const forcedLines = submittedLines.map((p, index) => {
const originalLine = linesToOrder?.[index];
const jobLineId = isReturn ? originalLine?.joblineid : originalLine?.id;
return {
...p,
job_line_id: jobLineId,
...(isReturn && { cm_received: false })
};
const forcedLines = buildSubmittedPartsOrderLines({
submittedLines,
linesToOrder,
isReturn
});
let insertResult;
@@ -147,10 +143,7 @@ export function PartsOrderModalContainer({
type: isReturn ? "jobspartsreturn" : "jobspartsorder"
});
// Use linesToOrder from context instead of form values to preserve job line ids
const jobLineIds = (linesToOrder ?? [])
.filter((line) => (isReturn ? line.joblineid : line.id))
.map((line) => (isReturn ? line.joblineid : line.id));
const jobLineIds = getSubmittedPartsOrderJobLineIds(forcedLines);
try {
const jobLinesResult = await updateJobLines({
@@ -206,23 +199,20 @@ export function PartsOrderModalContainer({
isinhouse: true,
date: dayjs(),
total: 0,
billlines: forcedLines.map((p, index) => {
const originalLine = linesToOrder?.[index];
return {
joblineid: isReturn ? originalLine?.joblineid : originalLine?.id,
actual_price: p.act_price,
actual_cost: 0, // p.act_price,
line_desc: p.line_desc,
line_remarks: p.line_remarks,
part_type: p.part_type,
quantity: p.quantity || 1,
applicable_taxes: {
local: false,
state: false,
federal: false
}
};
})
billlines: forcedLines.map((p) => ({
joblineid: p.job_line_id,
actual_price: p.act_price,
actual_cost: 0, // p.act_price,
line_desc: p.line_desc,
line_remarks: p.line_remarks,
part_type: p.part_type,
quantity: p.quantity || 1,
applicable_taxes: {
local: false,
state: false,
federal: false
}
}))
}
}
});

View File

@@ -0,0 +1,23 @@
export const getPartsOrderJobLineId = ({ line, originalLine, isReturn }) => {
return line?.job_line_id || (isReturn ? originalLine?.joblineid : originalLine?.id);
};
export const buildSubmittedPartsOrderLines = ({ submittedLines = [], linesToOrder = [], isReturn }) => {
return submittedLines.map((line, index) => {
const jobLineId = getPartsOrderJobLineId({
line,
originalLine: linesToOrder?.[index],
isReturn
});
return {
...line,
job_line_id: jobLineId,
...(isReturn && { cm_received: false })
};
});
};
export const getSubmittedPartsOrderJobLineIds = (partsOrderLines = []) => {
return partsOrderLines.map((line) => line.job_line_id).filter(Boolean);
};

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { buildSubmittedPartsOrderLines, getSubmittedPartsOrderJobLineIds } from "./parts-order-modal.utils.js";
describe("parts order modal utilities", () => {
it("preserves submitted job line ids after a row is removed", () => {
const submittedLines = [
{ line_desc: "second line", job_line_id: "job-line-2" },
{ line_desc: "third line", job_line_id: "job-line-3" }
];
const linesToOrder = [{ id: "job-line-1" }, { id: "job-line-2" }, { id: "job-line-3" }];
const result = buildSubmittedPartsOrderLines({ submittedLines, linesToOrder, isReturn: false });
expect(result.map((line) => line.job_line_id)).toEqual(["job-line-2", "job-line-3"]);
expect(getSubmittedPartsOrderJobLineIds(result)).toEqual(["job-line-2", "job-line-3"]);
});
it("falls back to original return line ids when the form omits hidden metadata", () => {
const submittedLines = [{ line_desc: "return line" }];
const linesToOrder = [{ joblineid: "return-job-line-1" }];
const result = buildSubmittedPartsOrderLines({ submittedLines, linesToOrder, isReturn: true });
expect(result).toEqual([
{
line_desc: "return line",
job_line_id: "return-job-line-1",
cm_received: false
}
]);
});
});

View File

@@ -29,7 +29,10 @@ const mapStateToProps = createStructuredSelector({
export function PartsQueueListComponent({ bodyshop }) {
const searchParams = queryString.parse(useLocation().search);
const { selected, sortcolumn, sortorder, statusFilters } = searchParams;
const { selected, sortcolumn, sortorder, statusFilters, page, pageSize } = searchParams;
const currentPage = Number.parseInt(page || "1", 10);
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
const history = useNavigate();
const [filter, setFilter] = useLocalStorage("filter_parts_queue", null);
const [viewTimeStamp, setViewTimeStamp] = useLocalStorage("parts_queue_timestamps", false);
@@ -66,7 +69,11 @@ export function PartsQueueListComponent({ bodyshop }) {
: [];
const handleTableChange = (pagination, filters, sorter) => {
// searchParams.page = pagination.current;
const nextPageSize = pagination?.pageSize || currentPageSize;
const pageSizeChanged = nextPageSize !== currentPageSize;
searchParams.pageSize = nextPageSize;
searchParams.page = pageSizeChanged ? 1 : pagination.current;
searchParams.sortcolumn = sorter.columnKey;
searchParams.sortorder = sorter.order;
@@ -315,9 +322,10 @@ export function PartsQueueListComponent({ bodyshop }) {
loading={loading}
pagination={{
placement: "top",
pageSize: pageLimit
// current: parseInt(page || 1),
// total: data && data.jobs_aggregate.aggregate.count,
pageSize: currentPageSize,
current: currentPage,
showSizeChanger: true,
total: jobs.length
}}
columns={columns}
mobileColumnKeys={["ro_number", "ownr_ln", "status", "vehicle", "partsstatus"]}

View File

@@ -1,4 +1,4 @@
import { MailOutlined, PrinterOutlined } from "@ant-design/icons";
import { MailOutlined, PrinterOutlined, SignatureFilled } from "@ant-design/icons";
import { Space, Spin } from "antd";
import { useState } from "react";
import { connect } from "react-redux";
@@ -10,6 +10,9 @@ import { GenerateDocument } from "../../utils/RenderTemplate";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import { HasFeatureAccess } from "./../feature-wrapper/feature-wrapper.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import axios from "axios";
import { setModalContext } from "../../redux/modals/modals.actions.js";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
const mapStateToProps = createStructuredSelector({
printCenterModal: selectPrintCenter,
@@ -17,12 +20,29 @@ const mapStateToProps = createStructuredSelector({
technician: selectTechnician
});
const mapDispatchToProps = () => ({});
const mapDispatchToProps = (dispatch) => ({
setEsignatureContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "esignature"
})
)
});
export function PrintCenterItemComponent({ printCenterModal, item, id, bodyshop, disabled, technician }) {
export function PrintCenterItemComponent({
printCenterModal,
setEsignatureContext,
item,
id,
bodyshop,
disabled,
technician
}) {
const [loading, setLoading] = useState(false);
const { context } = printCenterModal;
const notification = useNotification();
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
const renderToNewWindow = async () => {
setLoading(true);
@@ -39,6 +59,30 @@ export function PrintCenterItemComponent({ printCenterModal, item, id, bodyshop,
setLoading(false);
};
const esignatureGenerate = async () => {
setLoading(true);
try {
const {
data: { token, documentId, envelopeId }
} = await axios.post("/esign/new", {
name: item.key,
jobid: id,
context,
bodyshop,
templateObject: {
name: item.key,
variables: { id: id }
}
});
setEsignatureContext({ context: { token, documentId, envelopeId, jobid: id } });
} catch (error) {
console.log(error);
} finally {
setLoading(false);
}
};
if (
disabled ||
(item.featureNameRestricted && !HasFeatureAccess({ featureName: item.featureNameRestricted, bodyshop }))
@@ -54,6 +98,7 @@ export function PrintCenterItemComponent({ printCenterModal, item, id, bodyshop,
<li>
<Space wrap>
{item.title}
{esignatureEnabled && <SignatureFilled onClick={esignatureGenerate} />}
<PrinterOutlined onClick={renderToNewWindow} />
{!technician ? (
<MailOutlined

View File

@@ -9,11 +9,13 @@ import { selectPrintCenter } from "../../redux/modals/modals.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { TemplateList } from "../../utils/TemplateConstants";
import Jobd3RdPartyModal from "../job-3rd-party-modal/job-3rd-party-modal.component";
import EsignatureCustomDocument from "../esignature-custom-document/esignature-custom-document.component";
import PrintCenterItem from "../print-center-item/print-center-item.component";
import PrintCenterJobsLabels from "../print-center-jobs-labels/print-center-jobs-labels.component";
import PrintCenterSpeedPrint from "../print-center-speed-print/print-center-speed-print.component";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils";
import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import { selectTechnician } from "../../redux/tech/tech.selectors";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
const mapStateToProps = createStructuredSelector({
printCenterModal: selectPrintCenter,
@@ -36,6 +38,9 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
splitKey: bodyshop.imexshopid
});
const hasDMSKey = bodyshopHasDmsKey(bodyshop);
const dmsMode = getDmsMode(bodyshop, "off");
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
const Templates = !hasDMSKey
? Object.keys(tempList)
@@ -60,8 +65,9 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
(temp.regions && temp.regions[bodyshop.region_config]) ||
(temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)
)
.filter((temp) => !isReynoldsMode || !temp.excludedDmsModes?.includes(dmsMode))
.filter((temp) => !technician || temp.group !== "financial");
const JobsReportsList =
Enhanced_Payroll.treatment === "on"
? Object.keys(Templates)
@@ -94,6 +100,7 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
extra={
<Space wrap>
<PrintCenterJobsLabels jobId={jobId} />
{esignatureEnabled && <EsignatureCustomDocument jobId={jobId} />}
<Jobd3RdPartyModal jobId={jobId} job={job} />
<Input.Search onChange={(e) => setSearch(e.target.value)} value={search} enterButton />
</Space>

View File

@@ -80,14 +80,14 @@ const ModelInfoToolTip = ({ metadata, cardSettings }) =>
<Col span={24}>
<EllipsesToolTip
title={
metadata.v_model_yr || metadata.v_make_desc || metadata.v_model_desc
? `${metadata.v_model_yr || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}`
metadata.v_model_yr || metadata.v_make_desc || metadata.v_model_desc || metadata.v_color
? `${metadata.v_model_yr || ""} ${metadata.v_color || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}`
: null
}
kiosk={cardSettings.kiosk}
>
{metadata.v_model_yr || metadata.v_make_desc || metadata.v_model_desc ? (
`${metadata.v_model_yr || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}`
{metadata.v_model_yr || metadata.v_make_desc || metadata.v_model_desc || metadata.v_color ? (
`${metadata.v_model_yr || ""} ${metadata.v_color || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}`
) : (
<span>&nbsp;</span>
)}
@@ -431,6 +431,7 @@ export default function ProductionBoardCard({ technician, card, bodyshop, cardSe
<Card
className={`react-trello-card ${cardSettings.kiosk ? "kiosk-mode" : ""}`}
size="small"
styles={{ header: { backgroundColor: "var(--card-bg-fallback)" } }}
style={{
backgroundColor: cardSettings?.cardcolor
? bgColor.fallback || `rgba(${bgColor.r},${bgColor.g},${bgColor.b},${bgColor.a || 1})`

View File

@@ -28,11 +28,14 @@ const ProductionStatistics = ({ data, cardSettings, reducerData }) => {
const { t } = useTranslation();
const calculateTotal = (items, key, subKey) => {
return items.reduce((acc, item) => acc + (item[key]?.aggregate?.sum?.[subKey] || 0), 0);
return items.reduce((acc, item) => acc + (item?.[key]?.aggregate?.sum?.[subKey] ?? 0), 0);
};
const calculateTotalAmount = (items, key) => {
return items.reduce((acc, item) => acc.add(Dinero(item[key]?.totals?.subtotal ?? Dinero())), Dinero({ amount: 0 }));
return items.reduce(
(acc, item) => acc.add(Dinero(item?.[key]?.totals?.subtotal ?? Dinero())),
Dinero({ amount: 0 })
);
};
const calculateReducerTotalAmount = (lanes, key) => {
@@ -67,58 +70,83 @@ const ProductionStatistics = ({ data, cardSettings, reducerData }) => {
return value;
};
const filteredData = cardSettings.excludeSuspended === true ? data.filter((item) => item.suspended !== true) : data;
const filteredReducerData =
cardSettings.excludeSuspended === true
? {
...reducerData,
lanes: reducerData.lanes.map((lane) => ({
...lane,
cards: lane.cards.filter((card) => card.metadata.suspended !== true)
}))
}
: reducerData;
const totalHrs = cardSettings.totalHrs
? parseFloat((calculateTotal(data, "labhrs", "mod_lb_hrs") + calculateTotal(data, "larhrs", "mod_lb_hrs")).toFixed(2))
? parseFloat(
(
calculateTotal(filteredData, "labhrs", "mod_lb_hrs") + calculateTotal(filteredData, "larhrs", "mod_lb_hrs")
).toFixed(2)
)
: null;
const totalLAB = cardSettings.totalLAB
? parseFloat(calculateTotal(data, "labhrs", "mod_lb_hrs").toFixed(2))
? parseFloat(calculateTotal(filteredData, "labhrs", "mod_lb_hrs").toFixed(2))
: null;
const totalLAR = cardSettings.totalLAR
? parseFloat(calculateTotal(data, "larhrs", "mod_lb_hrs").toFixed(2))
? parseFloat(calculateTotal(filteredData, "larhrs", "mod_lb_hrs").toFixed(2))
: null;
const jobsInProduction = cardSettings.jobsInProduction ? data.length : null;
const jobsInProduction = cardSettings.jobsInProduction ? filteredData.length : null;
const totalAmountInProduction = cardSettings.totalAmountInProduction
? calculateTotalAmount(data, "job_totals").toFormat("$0,0.00")
? calculateTotalAmount(filteredData, "job_totals").toFormat("$0,0.00")
: null;
const totalAmountOnBoard = reducerData && cardSettings.totalAmountOnBoard
? calculateReducerTotalAmount(reducerData.lanes, "job_totals").toFormat("$0,0.00")
: null;
const totalAmountOnBoard =
filteredReducerData && cardSettings.totalAmountOnBoard
? calculateReducerTotalAmount(filteredReducerData.lanes, "job_totals").toFormat("$0,0.00")
: null;
const totalHrsOnBoard = reducerData && cardSettings.totalHrsOnBoard
? parseFloat((
calculateReducerTotal(reducerData.lanes, "labhrs", "mod_lb_hrs") +
calculateReducerTotal(reducerData.lanes, "larhrs", "mod_lb_hrs")
).toFixed(2))
: null;
const totalHrsOnBoard =
filteredReducerData && cardSettings.totalHrsOnBoard
? parseFloat(
(
calculateReducerTotal(filteredReducerData.lanes, "labhrs", "mod_lb_hrs") +
calculateReducerTotal(filteredReducerData.lanes, "larhrs", "mod_lb_hrs")
).toFixed(2)
)
: null;
const totalLABOnBoard = reducerData && cardSettings.totalLABOnBoard
? parseFloat(calculateReducerTotal(reducerData.lanes, "labhrs", "mod_lb_hrs").toFixed(2))
: null;
const totalLABOnBoard =
filteredReducerData && cardSettings.totalLABOnBoard
? parseFloat(calculateReducerTotal(filteredReducerData.lanes, "labhrs", "mod_lb_hrs").toFixed(2))
: null;
const totalLAROnBoard = reducerData && cardSettings.totalLAROnBoard
? parseFloat(calculateReducerTotal(reducerData.lanes, "larhrs", "mod_lb_hrs").toFixed(2))
: null;
const totalLAROnBoard =
filteredReducerData && cardSettings.totalLAROnBoard
? parseFloat(calculateReducerTotal(filteredReducerData.lanes, "larhrs", "mod_lb_hrs").toFixed(2))
: null;
const jobsOnBoard = reducerData && cardSettings.jobsOnBoard
? reducerData.lanes.reduce((acc, lane) => acc + lane.cards.length, 0)
: null;
const jobsOnBoard =
filteredReducerData && cardSettings.jobsOnBoard
? filteredReducerData.lanes.reduce((acc, lane) => acc + lane.cards.length, 0)
: null;
const tasksInProduction = cardSettings.tasksInProduction
? data.reduce((acc, item) => acc + (item.tasks_aggregate?.aggregate?.count || 0), 0)
? filteredData.reduce((acc, item) => acc + (item.tasks_aggregate?.aggregate?.count || 0), 0)
: null;
const tasksOnBoard = reducerData && cardSettings.tasksOnBoard
? reducerData.lanes.reduce((acc, lane) => {
return (
acc + lane.cards.reduce((laneAcc, card) => laneAcc + (card.metadata.tasks_aggregate?.aggregate?.count || 0), 0)
);
}, 0)
: null;
const tasksOnBoard =
filteredReducerData && cardSettings.tasksOnBoard
? filteredReducerData.lanes.reduce((acc, lane) => {
return (
acc +
lane.cards.reduce((laneAcc, card) => laneAcc + (card.metadata.tasks_aggregate?.aggregate?.count || 0), 0)
);
}, 0)
: null;
const statistics = mergeStatistics(statisticsItems, [
{ id: 0, value: totalHrs, type: StatisticType.HOURS },

View File

@@ -14,7 +14,16 @@ const StatisticsSettings = ({ t, statisticsOrder, setStatisticsOrder, setHasChan
};
return (
<Card title={t("production.settings.statistics_title")}>
<Card
title={t("production.settings.statistics_title")}
extra={
<div style={{ display: "flex", alignItems: "center" }}>
<Form.Item name="excludeSuspended" valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox>{t("production.settings.statistics.exclude_suspended")}</Checkbox>
</Form.Item>
</div>
}
>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable direction="grid" droppableId="statistics">
{(provided) => (

View File

@@ -91,7 +91,8 @@ const defaultKanbanSettings = {
subtotal: false,
statisticsOrder: statisticsItems.map((item) => item.id),
selectedMdInsCos: [],
selectedEstimators: []
selectedEstimators: [],
excludeSuspended: false
};
const defaultFilters = { search: "", employeeId: null, alert: false };

View File

@@ -140,13 +140,11 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
sortOrder: state.sortedInfo.columnKey === "vehicle" && state.sortedInfo.order,
render: (text, record) =>
technician ? (
<>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""} ${
record.v_color || ""
} ${record.plate_no || ""}`}</>
<>{`${record.v_model_yr || ""} ${record.v_color || ""}${record.v_make_desc || ""} ${record.v_model_desc || ""} ${record.plate_no || ""}`}</>
) : (
<Link to={`/manage/vehicles/${record.vehicleid}`}>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${
record.v_model_desc || ""
} ${record.v_color || ""} ${record.plate_no || ""}`}</Link>
<Link
to={`/manage/vehicles/${record.vehicleid}`}
>{`${record.v_model_yr || ""} ${record.v_color || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""} ${record.plate_no || ""}`}</Link>
)
},
{
@@ -621,7 +619,7 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
sortOrder: state.sortedInfo.columnKey === "dms_id" && state.sortedInfo.order
}
]
: []),
: [])
];
};
export default productionListColumnsData;

View File

@@ -12,6 +12,7 @@ import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries";
import { selectReportCenter } from "../../redux/modals/modals.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import DatePickerRanges from "../../utils/DatePickerRanges";
import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import { GenerateDocument } from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants";
import dayjs from "../../utils/day";
@@ -48,12 +49,18 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
const [loading, setLoading] = useState(false);
const { t } = useTranslation();
const Templates = TemplateList("report_center");
const dmsMode = getDmsMode(bodyshop, "off");
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
const ReportsList = Object.keys(Templates)
.map((key) => Templates[key])
.filter((temp) => {
const enhancedPayrollOn = Enhanced_Payroll.treatment === "on";
const adpPayrollOn = ADPPayroll.treatment === "on";
if (isReynoldsMode && temp.excludedDmsModes?.includes(dmsMode)) {
return false;
}
if (enhancedPayrollOn && adpPayrollOn) {
return temp.enhanced_payroll !== false || temp.adp_payroll !== false;
}
@@ -408,6 +415,6 @@ const restrictedReports = [
{ key: "job_costing_ro_estimator", days: 183 },
{ key: "job_lifecycle_date_detail", days: 183 },
{ key: "job_lifecycle_date_summary", days: 183 },
{ key: "customer_list", days: 183 },
{ key: "customer_list_excel", days: 183 }
{ key: "customer_list", days: 736 },
{ key: "customer_list_excel", days: 736 }
];

View File

@@ -48,7 +48,7 @@ export function ScheduleVerifyIntegrity({ currentUser }) {
setLoading(false);
};
if (currentUser.email === "patrick@imex.prod")
if (currentUser.email === "allan@imex.prod" || currentUser.email === "dave@imex.prod")
return (
<Button loading={loading} onClick={handleVerify}>
Developer Use Only - Verify Schedule Integrity

View File

@@ -4,7 +4,7 @@ import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import queryString from "query-string";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
@@ -52,6 +52,7 @@ const mapDispatchToProps = () => ({
});
export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDirty }) {
const submitActionRef = useRef("save");
const { t } = useTranslation();
const [internalIsDirty, setInternalIsDirty] = useState(false);
const resolvedIsDirty = typeof isDirty === "boolean" ? isDirty : internalIsDirty;
@@ -128,55 +129,117 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
});
}, [clearEmployeeFormMeta, currentEmployeeData, form]);
const syncEmployeeFormToSavedData = useCallback(
(employeeData) => {
if (employeeData) {
form.setFieldsValue(employeeData);
}
window.requestAnimationFrame(() => {
clearEmployeeFormMeta();
});
},
[clearEmployeeFormMeta, form]
);
useEffect(() => {
resetEmployeeFormToCurrentData();
}, [resetEmployeeFormToCurrentData, search.employeeId]);
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE);
const [insertEmployees] = useMutation(INSERT_EMPLOYEES);
const saveAndResetSubmitAction = useCallback(() => {
const submitAction = submitActionRef.current;
submitActionRef.current = "save";
return submitAction;
}, []);
const submitEmployeeForm = useCallback(
(submitAction = "save") => {
submitActionRef.current = submitAction;
form.submit();
},
[form]
);
const navigateToEmployee = useCallback(
(employeeId) => {
history({
search: queryString.stringify({
...search,
employeeId
})
});
},
[history, search]
);
const handleFinish = async (values) => {
const submitAction = saveAndResetSubmitAction();
const normalizedValues = {
...values,
user_email: values.user_email === "" ? null : values.user_email
};
const handleFinish = (values) => {
if (search.employeeId && search.employeeId !== "new") {
//Update a record.
logImEXEvent("shop_employee_update");
updateEmployee({
variables: {
id: search.employeeId,
employee: {
...values,
user_email: values.user_email === "" ? null : values.user_email
try {
const result = await updateEmployee({
variables: {
id: search.employeeId,
employee: normalizedValues
}
}
})
.then(() => {
updateDirtyState(false);
void refetch();
notification.success({
title: t("employees.successes.save")
});
})
.catch((error) => {
notification.error({
title: t("employees.errors.save", {
message: JSON.stringify(error)
})
});
});
} else {
//New record, insert it.
logImEXEvent("shop_employee_insert");
insertEmployees({
variables: { employees: [{ ...values, shopid: bodyshop.id }] },
refetchQueries: ["QUERY_EMPLOYEES"]
}).then((r) => {
updateDirtyState(false);
search.employeeId = r.data.insert_employees.returning[0].id;
history({ search: queryString.stringify(search) });
syncEmployeeFormToSavedData(result?.data?.update_employees?.returning?.[0] ?? normalizedValues);
void refetch();
if (submitAction === "saveAndNew") {
navigateToEmployee("new");
}
notification.success({
title: t("employees.successes.save")
});
} catch (error) {
notification.error({
title: t("employees.errors.save", {
message: JSON.stringify(error)
})
});
}
return;
}
//New record, insert it.
logImEXEvent("shop_employee_insert");
try {
const result = await insertEmployees({
variables: { employees: [{ ...normalizedValues, shopid: bodyshop.id }] },
refetchQueries: ["QUERY_EMPLOYEES"]
});
const savedEmployee = result?.data?.insert_employees?.returning?.[0];
if (submitAction === "saveAndNew") {
if (isNewEmployee) {
resetEmployeeFormToCurrentData();
}
navigateToEmployee("new");
} else if (savedEmployee?.id) {
syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues);
navigateToEmployee(savedEmployee.id);
} else {
syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues);
}
notification.success({
title: t("employees.successes.save")
});
} catch (error) {
notification.error({
title: t("employees.errors.save", {
message: JSON.stringify(error)
})
});
}
};
@@ -240,13 +303,24 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
<Card
title={employeeCardTitle}
extra={
<Button type="primary" onClick={() => form.submit()} disabled={!resolvedIsDirty} style={{ minWidth: 170 }}>
{t("employees.actions.save_employee")}
</Button>
<Space wrap>
<Button onClick={() => submitEmployeeForm("saveAndNew")} disabled={!resolvedIsDirty} style={{ minWidth: 170 }}>
{t("general.actions.saveandnew") || "Save and New"}
</Button>
<Button
type="primary"
onClick={() => submitEmployeeForm("save")}
disabled={!resolvedIsDirty}
style={{ minWidth: 170 }}
>
{t("employees.actions.save_employee")}
</Button>
</Space>
}
>
<Form
onFinish={handleFinish}
onFinishFailed={saveAndResetSubmitAction}
autoComplete={"off"}
layout="vertical"
form={form}

View File

@@ -0,0 +1,359 @@
import { useApolloClient } from "@apollo/client/react";
import { Form } from "antd";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useEffect } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
DELETE_VACATION,
INSERT_EMPLOYEES,
QUERY_EMPLOYEE_BY_ID,
UPDATE_EMPLOYEE
} from "../../graphql/employees.queries";
import { ShopEmployeesFormComponent } from "./shop-employees-form.component.jsx";
const insertEmployeesMock = vi.fn();
const updateEmployeeMock = vi.fn();
const deleteVacationMock = vi.fn();
const useQueryMock = vi.fn();
const useMutationMock = vi.fn();
const navigateMock = vi.fn();
const notification = {
error: vi.fn(),
success: vi.fn()
};
vi.mock("@apollo/client/react", async () => {
const actual = await vi.importActual("@apollo/client/react");
return {
...actual,
useApolloClient: vi.fn(),
useQuery: (...args) => useQueryMock(...args),
useMutation: (...args) => useMutationMock(...args)
};
});
vi.mock("@splitsoftware/splitio-react", () => ({
useTreatmentsWithConfig: () => ({
treatments: {
Enhanced_Payroll: {
treatment: "off"
}
}
})
}));
vi.mock("react-router-dom", () => ({
useLocation: () => ({
search: "?employeeId=new"
}),
useNavigate: () => navigateMock
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key, values = {}) => {
const translations = {
"bodyshop.labels.employee_options": "Employee Options",
"bodyshop.labels.employee_rates": "Employee Rates",
"bodyshop.labels.employee_vacation": "Employee Vacation",
"bodyshop.labels.employees": "Employees",
"employees.actions.addrate": "Add Rate",
"employees.actions.addvacation": "Add Vacation",
"employees.actions.new": "New Employee",
"employees.actions.save_employee": "Save Employee",
"employees.fields.active": "Active",
"employees.fields.employee_number": "Employee Number",
"employees.fields.external_id": "External Id",
"employees.fields.first_name": "First Name",
"employees.fields.flat_rate": "Flat Rate",
"employees.fields.hire_date": "Hire Date",
"employees.fields.last_name": "Last Name",
"employees.fields.pin": "PIN",
"employees.fields.rate": "Rate",
"employees.fields.termination_date": "Termination Date",
"employees.fields.user_email": "User Email",
"employees.labels.active": "Active",
"employees.successes.save": "Saved",
"general.actions.saveandnew": "Save and New",
"general.labels.actions": "Actions"
};
if (key === "employees.errors.save") {
return `Save failed: ${values.message ?? ""}`;
}
if (key === "employees.validation.unique_employee_number") {
return "Employee number must be unique";
}
if (key === "bodyshop.validation.useremailmustexist") {
return "User email must exist";
}
return translations[key] || key;
}
})
}));
vi.mock("../../contexts/Notifications/notificationContext.jsx", () => ({
useNotification: () => notification
}));
vi.mock("../../firebase/firebase.utils", () => ({
logImEXEvent: vi.fn()
}));
vi.mock("../alert/alert.component", () => ({
default: ({ title }) => <div>{title}</div>
}));
vi.mock("../form-fields-changed-alert/form-fields-changed-alert.component.jsx", () => ({
default: () => null
}));
vi.mock("../form-date-time-picker/form-date-time-picker.component.jsx", () => ({
default: ({ id, value, onChange }) => (
<input id={id} type="text" value={value ?? ""} onChange={(event) => onChange?.(event.target.value)} />
)
}));
vi.mock("../form-items-formatted/email-form-item.component.jsx", () => ({
default: ({ id, value, onChange }) => (
<input id={id} type="email" value={value ?? ""} onChange={(event) => onChange?.(event.target.value)} />
)
}));
vi.mock("../layout-form-row/layout-form-row.component", () => ({
default: ({ title, extra, actions, children }) => (
<div>
{title}
{extra}
{children}
{actions}
</div>
)
}));
vi.mock("../layout-form-row/inline-validated-form-row.component.jsx", () => ({
default: ({ title, extra, children }) => (
<div>
{title}
{extra}
{children}
</div>
)
}));
vi.mock("../layout-form-row/config-list-empty-state.component.jsx", () => ({
default: ({ actionLabel }) => <div>{actionLabel}</div>
}));
vi.mock("../form-list-move-arrows/form-list-move-arrows.component", () => ({
default: () => null
}));
vi.mock("../responsive-table/responsive-table.component", () => ({
default: () => null
}));
vi.mock("./shop-employees-add-vacation.component", () => ({
default: () => null
}));
vi.mock("../../utils/Ciecaselect", () => ({
default: () => []
}));
const bodyshop = {
id: "shop-1",
imexshopid: "split-shop-1",
md_responsibility_centers: {
costs: []
}
};
describe("ShopEmployeesFormComponent", () => {
let formInstance;
beforeEach(() => {
vi.clearAllMocks();
useQueryMock.mockImplementation((query) => {
if (query === QUERY_EMPLOYEE_BY_ID) {
return {
error: null,
data: null,
refetch: vi.fn(),
loading: false
};
}
return {
error: null,
data: null,
loading: false
};
});
useMutationMock.mockImplementation((mutation) => {
if (mutation === INSERT_EMPLOYEES) return [insertEmployeesMock];
if (mutation === UPDATE_EMPLOYEE) return [updateEmployeeMock];
if (mutation === DELETE_VACATION) return [deleteVacationMock];
return [vi.fn()];
});
useApolloClient.mockReturnValue({
query: vi.fn().mockResolvedValue({
data: {
employees_aggregate: {
aggregate: {
count: 0
},
nodes: []
},
users: []
}
})
});
insertEmployeesMock.mockResolvedValue({
data: {
insert_employees: {
returning: [
{
id: "employee-123",
first_name: "Jamie",
last_name: "Rivera",
employee_number: "42",
active: true,
termination_date: null,
hire_date: "2026-04-20",
flat_rate: false,
rates: [],
pin: "1234",
user_email: null
}
]
}
}
});
function TestHarness({ onFormReady }) {
const [form] = Form.useForm();
useEffect(() => {
onFormReady(form);
}, [form, onFormReady]);
return <ShopEmployeesFormComponent bodyshop={bodyshop} form={form} />;
}
render(
<TestHarness
onFormReady={(form) => {
formInstance = form;
}}
/>
);
});
it("marks a new employee form clean after save", async () => {
fireEvent.change(screen.getByRole("textbox", { name: "First Name" }), {
target: { value: "Jamie" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Last Name" }), {
target: { value: "Rivera" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Employee Number" }), {
target: { value: "42" }
});
fireEvent.change(screen.getByRole("textbox", { name: "PIN" }), {
target: { value: "1234" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Hire Date" }), {
target: { value: "2026-04-20" }
});
const saveButton = screen.getByRole("button", { name: "Save Employee" });
await waitFor(() => {
expect(saveButton.disabled).toBe(false);
});
fireEvent.click(saveButton);
await waitFor(() => {
expect(insertEmployeesMock).toHaveBeenCalledWith({
variables: {
employees: [
expect.objectContaining({
first_name: "Jamie",
last_name: "Rivera",
employee_number: "42",
pin: "1234",
hire_date: "2026-04-20",
shopid: "shop-1"
})
]
},
refetchQueries: ["QUERY_EMPLOYEES"]
});
});
await waitFor(() => {
expect(formInstance.isFieldsTouched()).toBe(false);
});
expect(notification.success).toHaveBeenCalledWith({
title: "Saved"
});
expect(navigateMock).toHaveBeenCalledWith({
search: "employeeId=employee-123"
});
});
it("saves a new employee and opens a fresh employee form when save and new is clicked", async () => {
fireEvent.change(screen.getByRole("textbox", { name: "First Name" }), {
target: { value: "Jamie" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Last Name" }), {
target: { value: "Rivera" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Employee Number" }), {
target: { value: "42" }
});
fireEvent.change(screen.getByRole("textbox", { name: "PIN" }), {
target: { value: "1234" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Hire Date" }), {
target: { value: "2026-04-20" }
});
fireEvent.click(screen.getByRole("button", { name: "Save and New" }));
await waitFor(() => {
expect(insertEmployeesMock).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(formInstance.isFieldsTouched()).toBe(false);
});
await waitFor(() => {
expect(screen.getByRole("textbox", { name: "First Name" })).toHaveValue("");
expect(screen.getByRole("textbox", { name: "Last Name" })).toHaveValue("");
expect(screen.getByRole("textbox", { name: "Employee Number" })).toHaveValue("");
expect(screen.getByRole("textbox", { name: "PIN" })).toHaveValue("");
expect(screen.getByRole("textbox", { name: "Hire Date" })).toHaveValue("");
});
expect(screen.getByText("New Employee")).toBeInTheDocument();
expect(navigateMock).toHaveBeenCalledWith({
search: "employeeId=new"
});
expect(notification.success).toHaveBeenCalledWith({
title: "Saved"
});
});
});

View File

@@ -12,6 +12,8 @@ import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.c
import { buildSectionActionButton, renderListOrEmpty } from "../layout-form-row/config-list-actions.utils.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils.js";
import {
INLINE_TITLE_GROUP_STYLE,
INLINE_TITLE_HANDLE_STYLE,
@@ -25,16 +27,21 @@ import {
const timeZonesList = Intl.supportedValuesOf("timeZone");
const mapStateToProps = createStructuredSelector({});
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoGeneral);
export function ShopInfoGeneral({ form }) {
export function ShopInfoGeneral({ form, bodyshop }) {
const { t } = useTranslation();
const insuranceCompanies = Form.useWatch(["md_ins_cos"], form) || [];
const duplicateInsuranceCompanyIndexes = getDuplicateIndexSetByNormalizedName(insuranceCompanies, "name");
const hasDMSKey = bodyshop ? bodyshopHasDmsKey(bodyshop) : false;
const dmsMode = bodyshop ? getDmsMode(bodyshop, "off") : "none";
const isReynoldsMode = hasDMSKey && dmsMode === DMS_MAP.reynolds;
return (
<div>
@@ -174,7 +181,9 @@ export function ShopInfoGeneral({ form }) {
>
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
<div style={INLINE_TITLE_SWITCH_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("bodyshop.fields.scoreboard_setup.ignore_blocked_days")}</div>
<div style={INLINE_TITLE_LABEL_STYLE}>
{t("bodyshop.fields.scoreboard_setup.ignore_blocked_days")}
</div>
<Form.Item noStyle name={["scoreboard_target", "ignoreblockeddays"]} valuePropName="checked">
<Switch />
</Form.Item>
@@ -311,7 +320,12 @@ export function ShopInfoGeneral({ form }) {
</Form.Item>
</Col>
<Col xs={24} sm={12} xl={8}>
<Form.Item key="use_fippa" label={t("bodyshop.fields.use_fippa")} name={["use_fippa"]} valuePropName="checked">
<Form.Item
key="use_fippa"
label={t("bodyshop.fields.use_fippa")}
name={["use_fippa"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
@@ -478,7 +492,12 @@ export function ShopInfoGeneral({ form }) {
<div style={INLINE_TITLE_LABEL_STYLE}>
{t("bodyshop.fields.system_settings.job_costing.use_paint_scale_data")}
</div>
<Form.Item noStyle key="use_paint_scale_data" name={["use_paint_scale_data"]} valuePropName="checked">
<Form.Item
noStyle
key="use_paint_scale_data"
name={["use_paint_scale_data"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
</div>
@@ -558,7 +577,12 @@ export function ShopInfoGeneral({ form }) {
</Form.Item>
</LayoutFormRow>
<LayoutFormRow header={t("bodyshop.labels.shop_enabled_features")} id="sharing" grow style={{ marginBottom: 0 }}>
<LayoutFormRow
header={t("bodyshop.labels.shop_enabled_features")}
id="sharing"
grow
style={{ marginBottom: 0 }}
>
<Form.Item
label={t("general.actions.sharetoteams")}
valuePropName="checked"
@@ -566,6 +590,16 @@ export function ShopInfoGeneral({ form }) {
>
<Switch />
</Form.Item>
{isReynoldsMode && (
<Form.Item
initialValue
label={t("bodyshop.fields.md_functionality_toggles.enhanced_early_ros")}
name={["md_functionality_toggles", "enhanced_early_ros"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
)}
</LayoutFormRow>
</div>
</>
@@ -1582,7 +1616,6 @@ export function ShopInfoGeneral({ form }) {
form={form}
errorNames={[["md_parts_order_comment", field.name, "label"]]}
noDivider
titleOnly
title={
<div style={INLINE_TITLE_ROW_STYLE}>
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />

View File

@@ -157,36 +157,36 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
</Col>
{HasFeatureAccess({ featureName: "export", bodyshop }) &&
ClosingPeriod.treatment === "on" && (
<Col xs={24} sm={12} xl={8}>
<Form.Item
key="ClosingPeriod"
name={["accountingconfig", "ClosingPeriod"]}
label={t("bodyshop.fields.closingperiod")}
>
<DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} />
</Form.Item>
</Col>
)}
<Col xs={24} sm={12} xl={8}>
<Form.Item
key="ClosingPeriod"
name={["accountingconfig", "ClosingPeriod"]}
label={t("bodyshop.fields.closingperiod")}
>
<DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} />
</Form.Item>
</Col>
)}
{HasFeatureAccess({ featureName: "export", bodyshop }) &&
ADPPayroll.treatment === "on" && (
<Col xs={24} sm={12} xl={8}>
<Form.Item
key="companyCode"
name={["accountingconfig", "companyCode"]}
label={t("bodyshop.fields.companycode")}
>
<Input />
</Form.Item>
</Col>
)}
<Col xs={24} sm={12} xl={8}>
<Form.Item
key="companyCode"
name={["accountingconfig", "companyCode"]}
label={t("bodyshop.fields.companycode")}
>
<Input />
</Form.Item>
</Col>
)}
{HasFeatureAccess({ featureName: "export", bodyshop }) &&
ADPPayroll.treatment === "on" && (
<Col xs={24} sm={12} xl={8}>
<Col xs={24} sm={12} xl={8}>
<Form.Item key="batchID" name={["accountingconfig", "batchID"]} label={t("bodyshop.fields.batchid")}>
<Input />
</Form.Item>
</Col>
)}
<Input />
</Form.Item>
</Col>
)}
{HasFeatureAccess({ featureName: "export", bodyshop }) && !hasDMSKey && (
<>
<Col xs={24} sm={12} xl={8}>
@@ -512,6 +512,15 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
>
<InputNumber min={0} max={100} suffix="%" />
</Form.Item>
{bodyshop.cdk_dealerid && (
<Form.Item
label={t("bodyshop.fields.dms.disablecontact")}
valuePropName="checked"
name={["cdk_configuration", "disablecontact"]}
>
<Switch />
</Form.Item>
)}
{bodyshop.pbs_serialnumber && (
<Form.Item
label={t("bodyshop.fields.dms.disablecontactvehiclecreation")}
@@ -810,16 +819,6 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
>
<Input onBlur={handleBlur} />
</Form.Item>
{!hasDMSKey && (
<Form.Item
label={t("bodyshop.fields.responsibilitycenter_accountitem")}
key={`${index}accountitem`}
name={[field.name, "accountitem"]}
rules={[{ required: true }]}
>
<Input onBlur={handleBlur} />
</Form.Item>
)}
{hasDMSKey && !bodyshop.rr_dealerid && (
<>
<Form.Item

View File

@@ -11,12 +11,13 @@ import ResponsiveTable from "../responsive-table/responsive-table.component";
export default function VehiclesListComponent({ loading, vehicles, total, refetch, basePath = "/manage" }) {
const search = queryString.parse(useLocation().search);
const {
page
//sortcolumn, sortorder,
} = search;
const { page, pageSize } = search;
const history = useNavigate();
const currentPage = Number.parseInt(page || "1", 10);
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
const [state, setState] = useState({
sortedInfo: {},
filteredInfo: { text: "" }
@@ -43,9 +44,7 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc
key: "description",
render: (text, record) => {
return (
<span>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${
record.v_model_desc || ""
} ${record.v_color || ""}`}</span>
<span>{`${record.v_model_yr || ""} ${record.v_color || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""} `}</span>
);
}
},
@@ -62,10 +61,14 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc
];
const handleTableChange = (pagination, filters, sorter) => {
const nextPageSize = pagination?.pageSize || currentPageSize;
const pageSizeChanged = nextPageSize !== currentPageSize;
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
const updatedSearch = {
...search,
page: pagination.current,
pageSize: nextPageSize,
page: pageSizeChanged ? 1 : pagination.current,
sortcolumn: sorter.columnKey,
sortorder: sorter.order
};
@@ -106,7 +109,13 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc
>
<ResponsiveTable
loading={loading}
pagination={{ placement: "top", pageSize: pageLimit, current: parseInt(page || 1), total: total }}
pagination={{
placement: "top",
pageSize: currentPageSize,
current: currentPage,
showSizeChanger: true,
total: total
}}
columns={columns}
mobileColumnKeys={["v_vin", "description", "plate_no"]}
rowKey="id"

View File

@@ -16,14 +16,18 @@ const mapStateToProps = createStructuredSelector({
export function VehiclesListContainer({ isPartsEntry }) {
const searchParams = queryString.parse(useLocation().search);
const { page, sortcolumn, sortorder, search } = searchParams;
const { page, sortcolumn, sortorder, search, pageSize } = searchParams;
const basePath = getPartsBasePath(isPartsEntry);
const currentPage = Number.parseInt(page || "1", 10);
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
const { loading, error, data, refetch } = useQuery(QUERY_ALL_VEHICLES_PAGINATED, {
variables: {
search: search || "",
offset: page ? (page - 1) * pageLimit : 0,
limit: pageLimit,
offset: (currentPage - 1) * currentPageSize,
limit: currentPageSize,
order: [
{
[sortcolumn || "created_at"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc"

View File

@@ -1,9 +1,9 @@
import { getAnalytics, logEvent } from "@firebase/analytics";
//import { getAnalytics, logEvent } from "@firebase/analytics";
import { initializeApp } from "@firebase/app";
import { getAuth, updatePassword, updateProfile } from "@firebase/auth";
import { getFirestore } from "@firebase/firestore";
import { getMessaging, getToken, onMessage } from "@firebase/messaging";
import { store } from "../redux/store";
//import { store } from "../redux/store";
//import * as amplitude from '@amplitude/analytics-browser';
// import posthog from 'posthog-js'
@@ -12,7 +12,7 @@ initializeApp(config);
export const auth = getAuth();
export const firestore = getFirestore();
export const analytics = getAnalytics();
//export const analytics = getAnalytics();
//export default firebase;
export const getCurrentUser = () => {
@@ -72,34 +72,36 @@ onMessage(messaging, (payload) => {
// ...
});
export const logImEXEvent = (eventName, additionalParams, stateProp = null) => {
try {
const state = stateProp || store.getState();
// eslint-disable-next-line no-unused-vars
export const logImEXEvent = (eventName, additionalParams, _stateProp = null) => {
// Disabled as a part of IO-3712.
// try {
// const state = stateProp || store.getState();
const eventParams = {
shop: (state.user && state.user.bodyshop && state.user.bodyshop.shopname) || null,
user: (state.user && state.user.currentUser && state.user.currentUser.email) || null,
partsManagementOnly: state?.user?.partsManagementOnly,
...additionalParams
};
// axios.post("/ioevent", {
// useremail: (state.user && state.user.currentUser && state.user.currentUser.email) || null,
// bodyshopid: (state.user && state.user.bodyshop && state.user.bodyshop.id) || null,
// operationName: eventName,
// variables: additionalParams,
// dbevent: false,
// env: `master-AIO|${import.meta.env.VITE_APP_GIT_SHA_DATE}`
// });
// console.log(
// "%c[Analytics]",
// "background-color: green ;font-weight:bold;",
// eventName,
// eventParams
// );
logEvent(analytics, eventName, eventParams);
//amplitude.track(eventName, eventParams);
//posthog.capture(eventName, eventParams);
} finally {
//If it fails, just keep going.
}
// const eventParams = {
// shop: (state.user && state.user.bodyshop && state.user.bodyshop.shopname) || null,
// user: (state.user && state.user.currentUser && state.user.currentUser.email) || null,
// partsManagementOnly: state?.user?.partsManagementOnly,
// ...additionalParams
// };
// // axios.post("/ioevent", {
// // useremail: (state.user && state.user.currentUser && state.user.currentUser.email) || null,
// // bodyshopid: (state.user && state.user.bodyshop && state.user.bodyshop.id) || null,
// // operationName: eventName,
// // variables: additionalParams,
// // dbevent: false,
// // env: `master-AIO|${import.meta.env.VITE_APP_GIT_SHA_DATE}`
// // });
// // console.log(
// // "%c[Analytics]",
// // "background-color: green ;font-weight:bold;",
// // eventName,
// // eventParams
// // );
// logEvent(analytics, eventName, eventParams);
// //amplitude.track(eventName, eventParams);
// //posthog.capture(eventName, eventParams);
// } finally {
// //If it fails, just keep going.
// }
};

View File

@@ -22,6 +22,23 @@ export const QUERY_AUDIT_TRAIL = gql`
useremail
status
}
esignature_documents(where: {jobid: {_eq: $jobid}}) {
id
created_at
updated_at
jobid
external_document_id
subject
message
title
status
recipients
completed_at
opened
completed
rejected
completed_at
}
}
`;

View File

@@ -53,6 +53,7 @@ export const QUERY_BODYSHOP = gql`
phone
federal_tax_id
id
documenso_api_key
insurance_vendor_id
logo_img_path
md_ro_statuses

View File

@@ -14,8 +14,8 @@ import reportWebVitals from "./reportWebVitals";
import "./translations/i18n";
import "./utils/CleanAxios";
// import * as amplitude from "@amplitude/analytics-browser";
import { PostHogProvider } from "posthog-js/react";
import posthog from "posthog-js";
//import { PostHogProvider } from "posthog-js/react";
//import posthog from "posthog-js";
import { StrictMode } from "react";
window.global ||= window;
@@ -44,11 +44,11 @@ Dinero.globalRoundingMode = "HALF_EVEN";
// // }
// });
posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, {
autocapture: false,
capture_exceptions: true,
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST
});
// posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, {
// autocapture: false,
// capture_exceptions: true,
// api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST
// });
const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV6(createBrowserRouter);
@@ -70,9 +70,7 @@ function App() {
return (
<Provider store={store}>
<PersistGate loading={<LoadingSpinner message="Restoring your settings..." />} persistor={persistor}>
<PostHogProvider client={posthog}>
<RouterProvider router={router} />
</PostHogProvider>
</PersistGate>
</Provider>
);

View File

@@ -18,16 +18,20 @@ const mapStateToProps = createStructuredSelector({});
export function ExportLogsPageComponent() {
const searchParams = queryString.parse(useLocation().search);
const { page, sortcolumn, sortorder, search } = searchParams;
const { page, sortcolumn, sortorder, search, pageSize } = searchParams;
const history = useNavigate();
const currentPage = Number.parseInt(page || "1", 10);
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
const { loading, error, data, refetch } = useQuery(QUERY_EXPORT_LOG_PAGINATED, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
variables: {
search: search || "",
offset: page ? (page - 1) * pageLimit : 0,
limit: pageLimit,
offset: (currentPage - 1) * currentPageSize,
limit: currentPageSize,
order: [
{
...(sortcolumn === "ro_number"
@@ -61,7 +65,11 @@ export function ExportLogsPageComponent() {
if (error) return <AlertComponent title={error.message} type="error" />;
const handleTableChange = (pagination, filters, sorter) => {
searchParams.page = pagination.current;
const nextPageSize = pagination?.pageSize || currentPageSize;
const pageSizeChanged = nextPageSize !== currentPageSize;
searchParams.pageSize = nextPageSize;
searchParams.page = pageSizeChanged ? 1 : pagination.current;
searchParams.sortcolumn = sorter.columnKey;
searchParams.sortorder = sorter.order;
if (filters.status) {
@@ -191,8 +199,9 @@ export function ExportLogsPageComponent() {
loading={loading}
pagination={{
placement: "top",
pageSize: pageLimit,
current: parseInt(page || 1, 10),
pageSize: currentPageSize,
current: currentPage,
showSizeChanger: true,
total: data && data.search_exportlog_aggregate.aggregate.count
}}
columns={columns}

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery } from "@apollo/client/react";
import { Button, Card, Col, Form, Input, Modal, Result, Row, Select, Space, Switch, Typography } from "antd";
import { useEffect, useState, useCallback } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useParams } from "react-router-dom";
@@ -23,9 +23,8 @@ import LoadingSpinner from "../../components/loading-spinner/loading-spinner.com
import NotFound from "../../components/not-found/not-found.component";
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
import RREarlyROModal from "../../components/dms-post-form/rr-early-ro-modal";
import { GET_JOB_BY_PK, CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { CONVERT_JOB_TO_RO, GET_JOB_BY_PK } from "../../graphql/jobs.queries";
import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket";
@@ -302,7 +301,11 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop
}
]}
>
<Select>
<Select
showSearch={{
optionFilterProp: "children"
}}
>
{bodyshop?.md_referral_sources?.map((s) => (
<Select.Option key={s} value={s}>
{s}
@@ -379,7 +382,13 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop
</Form.Item>
<Space wrap style={{ marginTop: 16 }}>
<Button disabled={submitDisabled()} type="primary" danger onClick={() => form.submit()} loading={convertLoading}>
<Button
disabled={submitDisabled()}
type="primary"
danger
onClick={() => form.submit()}
loading={convertLoading}
>
{t("jobs.actions.convert")}
</Button>
<Button onClick={() => setShowConvertModal(false)}>{t("general.actions.close")}</Button>

View File

@@ -20,6 +20,7 @@ import { FaHardHat, FaRegStickyNote, FaShieldAlt, FaTasks } from "react-icons/fa
import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import EsignatureCustomDocument from "../../components/esignature-custom-document/esignature-custom-document.component.jsx";
import FormFieldsChanged from "../../components/form-fields-changed-alert/form-fields-changed-alert.component";
import JobAuditTrail from "../../components/job-audit-trail/job-audit-trail.component";
import JobsLinesContainer from "../../components/job-detail-lines/job-lines.container";
@@ -56,6 +57,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { DateTimeFormat } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
import UndefinedToNull from "../../utils/undefinedtonull";
const mapStateToProps = createStructuredSelector({
@@ -104,6 +106,7 @@ export function JobsDetailPage({
});
const notification = useNotification();
const { scenarioNotificationsOn } = useSocket();
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
useEffect(() => {
//form.setFieldsValue(transormJobToForm(job));
@@ -285,6 +288,7 @@ export function JobsDetailPage({
>
{t("general.labels.refresh")}
</Button>
{esignatureEnabled && <EsignatureCustomDocument jobId={job.id} />}
<JobsChangeStatus job={job} />
<JobSyncButton job={job} />
<Button

View File

@@ -30,6 +30,8 @@ import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
import useAlertsNotifications from "../../hooks/useAlertsNotifications.jsx";
import { selectDarkMode } from "../../redux/application/application.selectors.js";
import { lazyDev } from "../../utils/lazyWithPreload.jsx";
import EsignatureModalContainer from "../../components/esignature-modal/esignature-modal.container.jsx";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
const PrintCenterModalContainer = lazyDev(
() => import("../../components/print-center-modal/print-center-modal.container")
@@ -68,7 +70,9 @@ const FeatureRequestPage = lazyDev(() => import("../feature-request/feature-requ
const JobCostingModal = lazyDev(() => import("../../components/job-costing-modal/job-costing-modal.container"));
const ReportCenterModal = lazyDev(() => import("../../components/report-center-modal/report-center-modal.container"));
const BillEnterModalContainer = lazyDev(() => import("../../components/bill-enter-modal/bill-enter-modal.container"));
const TimeTicketModalContainer = lazyDev(() => import("../../components/time-ticket-modal/time-ticket-modal.container"));
const TimeTicketModalContainer = lazyDev(
() => import("../../components/time-ticket-modal/time-ticket-modal.container")
);
const TimeTicketModalTask = lazyDev(
() => import("../../components/time-ticket-task-modal/time-ticket-task-modal.container")
);
@@ -110,7 +114,9 @@ const TtApprovals = lazyDev(() => import("../tt-approvals/tt-approvals.page.cont
const MyTasksPage = lazyDev(() => import("../tasks/myTasksPageContainer.jsx"));
const AllTasksPage = lazyDev(() => import("../tasks/allTasksPageContainer.jsx"));
const TaskUpsertModalContainer = lazyDev(() => import("../../components/task-upsert-modal/task-upsert-modal.container"));
const TaskUpsertModalContainer = lazyDev(
() => import("../../components/task-upsert-modal/task-upsert-modal.container")
);
const { Content } = Layout;
const mapStateToProps = createStructuredSelector({
@@ -123,6 +129,7 @@ const mapStateToProps = createStructuredSelector({
export function Manage({ conflict, bodyshop, partsManagementOnly, isDarkMode, currentUser }) {
const { t } = useTranslation();
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
const [chatVisible] = useState(false);
const didMount = useRef(false);
@@ -178,6 +185,7 @@ export function Manage({ conflict, bodyshop, partsManagementOnly, isDarkMode, cu
<TaskUpsertModalContainer />
<BreadCrumbs />
<BillEnterModalContainer />
{esignatureEnabled && <EsignatureModalContainer />}
<JobCostingModal />
<ReportCenterModal />
<EmailOverlayContainer />

View File

@@ -27,12 +27,19 @@ const mapDispatchToProps = (dispatch) => ({
});
export function TechAssignedProdJobs({ setTimeTicketTaskContext, technician, bodyshop }) {
const technicianId = technician?.id;
const teamIds = (bodyshop?.employee_teams || [])
.filter((employeeTeam) =>
employeeTeam?.employee_team_members?.some((teamMember) => teamMember?.employeeid === technicianId)
)
.map((employeeTeam) => employeeTeam.id)
.filter(Boolean);
const hasAssignedTeams = Boolean(technicianId) && teamIds.length > 0;
const { loading, error, data, refetch } = useQuery(QUERY_JOBS_TECH_ASIGNED_TO_BY_TEAM, {
variables: {
teamIds: bodyshop.employee_teams
.filter((et) => et.employee_team_members.find((etm) => etm.employeeid === technician.id))
.map((et) => et.id)
}
teamIds
},
skip: !technicianId || !hasAssignedTeams
});
const searchParams = queryString.parse(useLocation().search);
@@ -177,7 +184,7 @@ export function TechAssignedProdJobs({ setTimeTicketTaskContext, technician, bod
<Card
extra={
<Space wrap>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Button disabled={!hasAssignedTeams} onClick={() => refetch()} icon={<SyncOutlined />} />
<Input.Search
placeholder={t("general.labels.search")}
onChange={(e) => {

View File

@@ -27,7 +27,8 @@ const INITIAL_STATE = {
contractFinder: { ...baseModal },
inventoryUpsert: { ...baseModal },
ca_bc_eftTableConvert: { ...baseModal },
cardPayment: { ...baseModal }
cardPayment: { ...baseModal },
esignature: { ...baseModal }
};
const modalsReducer = (state = INITIAL_STATE, action) => {

View File

@@ -36,3 +36,4 @@ export const selectInventoryUpsert = createSelector([selectModals], (modals) =>
export const selectCaBcEtfTableConvert = createSelector([selectModals], (modals) => modals.ca_bc_eftTableConvert);
export const selectCardPayment = createSelector([selectModals], (modals) => modals.cardPayment);
export const selectEsignature = createSelector([selectModals], (modals) => modals.esignature);

View File

@@ -1,5 +1,5 @@
import FingerprintJS from "@fingerprintjs/fingerprintjs";
import { setUserId, setUserProperties } from "@firebase/analytics";
//import { setUserId, setUserProperties } from "@firebase/analytics";
import {
checkActionCode,
confirmPasswordReset,
@@ -9,14 +9,13 @@ import {
} from "@firebase/auth";
import { arrayUnion, doc, getDoc, setDoc, updateDoc } from "@firebase/firestore";
import { getToken } from "@firebase/messaging";
import * as Sentry from "@sentry/react";
// import * as Sentry from "@sentry/react";
import { notification } from "antd";
import axios from "axios";
import i18next from "i18next";
import LogRocket from "logrocket";
//import LogRocket from "logrocket";
import { all, call, delay, put, select, takeLatest } from "redux-saga/effects";
import {
analytics,
auth,
firestore,
getCurrentUser,
@@ -49,7 +48,7 @@ import {
validatePasswordResetSuccess
} from "./user.actions";
import UserActionTypes from "./user.types";
import posthog from "posthog-js";
//import posthog from "posthog-js";
import { bodyshopHasDmsKey, determineDMSTypeByBodyshop, DMS_MAP } from "../../utils/dmsUtils";
const fpPromise = FingerprintJS.load();
@@ -91,9 +90,9 @@ export function* isUserAuthenticated() {
return;
}
LogRocket.identify(user.email);
//LogRocket.identify(user.email);
//amplitude.setUserId(user.email);
posthog.identify(user.email);
//posthog.identify(user.email);
const eulaQuery = yield client.query({
query: QUERY_EULA,
@@ -234,7 +233,7 @@ export function* onSignInSuccess() {
}
export function* signInSuccessSaga({ payload }) {
LogRocket.identify(payload.email);
//LogRocket.identify(payload.email);
try {
window.$crisp?.push(["set", "user:nickname", [payload.displayName || payload.email]]);
@@ -279,17 +278,17 @@ export function* signInSuccessSaga({ payload }) {
console.log("Error updating Crisp settings.", error);
}
try {
Sentry.setUser({
email: payload.email,
username: payload.displayName || payload.email
});
} catch (error) {
console.log("Error setting Sentry user.", error);
}
// try {
// Sentry.setUser({
// email: payload.email,
// username: payload.displayName || payload.email
// });
// } catch (error) {
// console.log("Error setting Sentry user.", error);
// }
setUserId(analytics, payload.email);
setUserProperties(analytics, payload);
// setUserId(analytics, payload.email);
// setUserProperties(analytics, payload);
yield;
}

View File

@@ -323,14 +323,14 @@
"addtemplate": "Add Template",
"newlaborrate": "New Labor Rate",
"newsalestaxcode": "New Sales Tax Code",
"save_shop_information": "Save Shop Information",
"newstatus": "Add Status",
"save_shop_information": "Save Shop Information",
"testrender": "Test Render"
},
"errors": {
"creatingdefaultview": "Error creating default view.",
"duplicate_job_status": "Duplicate job status. Each job status must be unique.",
"duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique",
"duplicate_job_status": "Duplicate job status. Each job status must be unique.",
"loading": "Unable to load shop details. Please call technical support.",
"saving": "Error encountered while saving. {{message}}",
"task_preset_allocation_exceeded": "{{laborType}} task preset total is {{total}}% and cannot exceed 100%."
@@ -370,6 +370,7 @@
"cashierid": "Cashier ID",
"default_journal": "Default Journal",
"disablebillwip": "Disable bill WIP for A/P Posting",
"disablecontact": "Disable Contact Updates/Creation",
"disablecontactvehiclecreation": "Disable Contact & Vehicle Updates/Creation",
"dms_acctnumber": "DMS Account #",
"dms_control_override": "Static Control # Override",
@@ -427,35 +428,6 @@
"logo_img_path": "Shop Logo",
"logo_img_path_height": "Logo Image Height",
"logo_img_path_width": "Logo Image Width",
"scoreboard_setup": {
"daily_body_target": "Daily Body Target",
"daily_paint_target": "Daily Paint Target",
"ignore_blocked_days": "Ignore Blocked Days",
"last_number_working_days": "Last Number of Working Days",
"production_target_hours": "Production Target Hours"
},
"system_settings": {
"auto_email": {
"attach_pdf_to_email": "Attach PDF to Sent Emails?",
"from_emails": "Additional From Emails",
"parts_order_cc": "Parts Orders CC",
"parts_return_slip_cc": "Parts Returns CC"
},
"job_costing": {
"paint_hour_split": "Paint Hour Split",
"paint_materials_hourly_cost_rate": "Paint Materials Hourly Cost Rate",
"prep_hour_split": "Prep Hour Split",
"shop_materials_hourly_cost_rate": "Shop Materials Hourly Cost Rate",
"target_touch_time": "Target Touch Time",
"use_paint_scale_data": "Use Paint Scale Data"
},
"local_media_server": {
"enabled": "Enabled",
"http_path": "HTTP Path",
"network_path": "Network Path",
"token": "Token"
}
},
"md_categories": "Categories",
"md_ccc_rates": "Courtesy Car Contract Rate Presets",
"md_classes": "Classes",
@@ -463,6 +435,7 @@
"md_email_cc": "Auto Email CC: $t(parts_orders.labels.{{template}})",
"md_from_emails": "Additional From Emails",
"md_functionality_toggles": {
"enhanced_early_ros": "Enable Enhance Early ROs",
"parts_queue_toggle": "Auto Add Imported/Supplemented Jobs to Parts Queue"
},
"md_hour_split": {
@@ -711,6 +684,13 @@
},
"schedule_end_time": "Schedule Ending Time",
"schedule_start_time": "Schedule Starting Time",
"scoreboard_setup": {
"daily_body_target": "Daily Body Target",
"daily_paint_target": "Daily Paint Target",
"ignore_blocked_days": "Ignore Blocked Days",
"last_number_working_days": "Last Number of Working Days",
"production_target_hours": "Production Target Hours"
},
"shopname": "Shop Name",
"speedprint": {
"id": "Id",
@@ -757,6 +737,28 @@
"production_statuses": "Production Statuses",
"ready_statuses": "Ready Statuses"
},
"system_settings": {
"auto_email": {
"attach_pdf_to_email": "Attach PDF to Sent Emails?",
"from_emails": "Additional From Emails",
"parts_order_cc": "Parts Orders CC",
"parts_return_slip_cc": "Parts Returns CC"
},
"job_costing": {
"paint_hour_split": "Paint Hour Split",
"paint_materials_hourly_cost_rate": "Paint Materials Hourly Cost Rate",
"prep_hour_split": "Prep Hour Split",
"shop_materials_hourly_cost_rate": "Shop Materials Hourly Cost Rate",
"target_touch_time": "Target Touch Time",
"use_paint_scale_data": "Use Paint Scale Data"
},
"local_media_server": {
"enabled": "Enabled",
"http_path": "HTTP Path",
"network_path": "Network Path",
"token": "Token"
}
},
"target_touchtime": "Target Touch Time",
"timezone": "Timezone",
"tt_allow_post_to_invoiced": "Allow Time Tickets to be posted to Invoiced & Exported Jobs",
@@ -776,6 +778,7 @@
"alljobstatuses": "All Job Statuses",
"allopenjobstatuses": "All Open Job Statuses",
"apptcolors": "Appointment Colors",
"autoemail": "Auto Email",
"businessinformation": "Business Information",
"checklists": "Checklists",
"consent_settings": "Phone Number Opt-Out List",
@@ -783,7 +786,6 @@
"customtemplates": "Custom Templates",
"defaultcostsmapping": "Default Costs Mapping",
"defaultprofitsmapping": "Default Profits Mapping",
"dms_setup": "DMS Setup",
"deliverchecklist": "Delivery Checklist",
"dms": {
"cdk": {
@@ -798,10 +800,11 @@
"rr_dealerid": "Reynolds Store Number",
"title": "DMS"
},
"dms_setup": "DMS Setup",
"emaillater": "Email Later",
"employee_teams": "Employee Teams",
"employee_options": "Employee Options",
"employee_rates": "Employee Rates",
"employee_teams": "Employee Teams",
"employee_vacation": "Employee Vacation",
"employees": "Employees",
"estimators": "Estimators",
@@ -812,21 +815,22 @@
"intakechecklist": "Intake Checklist",
"intellipay_cash_discount": "Please ensure that cash discounting has been enabled on your merchant account. Reach out to IntelliPay Support if you need assistance. ",
"job_status_options": "Job Status Options",
"jobcosting": "Job Costing",
"jobstatuses": "Job Statuses",
"jump_to_section": "Jump to section",
"laborrates": "Labor Rates",
"licensing": "Licensing",
"localmediaserver": "Local Media Server",
"md_parts_scan": "Parts Scan Rules",
"md_ro_guard": "RO Guard",
"md_ro_guard_options": "RO Guard Options",
"md_tasks_presets": "Tasks Presets",
"task_preset_options": "Task Preset Options",
"md_to_emails": "Preset To Emails",
"md_to_emails_emails": "Emails",
"messagingpresets": "Messaging Presets",
"notification_options": "Notification Options",
"notemplatesavailable": "No templates available to add.",
"notespresets": "Notes Presets",
"jump_to_section": "Jump to section",
"notification_options": "Notification Options",
"notifications": {
"followers": "Notifications"
},
@@ -863,9 +867,6 @@
"roguard": {
"title": "RO Guard"
},
"autoemail": "Auto Email",
"jobcosting": "Job Costing",
"localmediaserver": "Local Media Server",
"romepay": "Rome Pay",
"scheduling": "SMART Scheduling",
"scoreboardsetup": "Scoreboard Setup",
@@ -877,6 +878,7 @@
"ssbuckets": "Job Size Definitions",
"systemsettings": "System Settings",
"task-presets": "Task Presets",
"task_preset_options": "Task Preset Options",
"workingdays": "Working Days"
},
"operations": {
@@ -1221,6 +1223,7 @@
"confirmdelete": "Are you sure you want to delete these documents. This CANNOT be undone.",
"doctype": "Document Type",
"dragtoupload": "Click or drag files to this area to upload",
"greyscale": "Greyscale",
"newjobid": "Assign to Job",
"openinexplorer": "Open in Explorer",
"optimizedimage": "The below image is optimized. Click on the picture below to open in a new window and view it full size, or open it in explorer.",
@@ -1351,6 +1354,31 @@
"unique_employee_number": "You must enter a unique employee number."
}
},
"esignature": {
"actions": {
"delete": "Delete",
"distribute": "Distribute",
"redistribute": "Redistribute",
"upload_document": "Upload Document for E-Sign",
"view": "View"
},
"errors": {
"no_token": "Error connecting to signing server. No authorization token was provided.",
"pdf_only": "Only PDF documents can be uploaded for e-signature.",
"upload_title": "Unable to prepare document for e-signature"
},
"fields": {
"completed": "Completed?",
"completed_at": "Completed At",
"created_at": "Created At",
"external_document_id": "Ex. Document ID",
"opened": "Opened?",
"rejected": "Rejected?",
"status": "Status",
"title": "Title",
"updated_at": "Updated At"
}
},
"eula": {
"buttons": {
"accept": "Accept EULA"
@@ -1466,8 +1494,8 @@
"beta": "BETA",
"cancel": "Are you sure you want to cancel? Your changes will not be saved.",
"changelog": "Change Log",
"click_to_begin": "Click {{action}} to begin",
"clear": "Clear",
"click_to_begin": "Click {{action}} to begin",
"confirmpassword": "Confirm Password",
"created_at": "Created At",
"date": "Select Date",
@@ -1787,6 +1815,7 @@
"actions": {
"addDocuments": "Add Job Documents",
"addNote": "Add Note",
"addpayer": "Add Payer",
"addtopartsqueue": "Add to Parts Queue",
"addtoproduction": "Add to Production",
"addtoscoreboard": "Add to Scoreboard",
@@ -1963,6 +1992,7 @@
"ded_status": "Deductible Status",
"depreciation_taxes": "Betterment/Depreciation/Taxes",
"dms": {
"IsARCustomer": "AR Customer?",
"address": "Customer Address",
"advisor": "Advisor #",
"amount": "Amount",
@@ -2313,6 +2343,8 @@
"duplicateconfirm": "Are you sure you want to duplicate this Job? Some elements of this Job will not be duplicated.",
"emailaudit": "Email Audit Trail",
"employeeassignments": "Employee Assignments",
"esignature_imex": "ImEX Sign",
"esignature_rome": "Rome Sign",
"estimatelines": "Estimate Lines",
"estimator": "Estimator",
"existing_jobs": "Existing Jobs",
@@ -2752,6 +2784,9 @@
"alternate-transport-changed": "Alternate Transport Changed",
"bill-posted": "Bill Posted",
"critical-parts-status-changed": "Critical Parts Status Changed",
"esign-document-completed": "E-Sign Document Completed",
"esign-document-opened": "E-Sign Document Opened",
"esign-document-upload-failed": "E-Sign Document Upload Failed",
"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",
@@ -3264,6 +3299,7 @@
"information": "Information",
"layout": "Layout",
"statistics": {
"exclude_suspended": "Exclude Suspended Jobs",
"jobs_in_production": "Jobs in Production",
"tasks_in_production": "Tasks in Production",
"tasks_in_view": "Tasks in View",
@@ -3755,11 +3791,11 @@
"jobhours": "Job Related Time Tickets Summary",
"lunch": "Lunch",
"new": "New Time Ticket",
"payrollclaimedtasks": "These time tickets will be automatically entered to the system as a part of claiming this task. These numbers are calculated using the jobs assigned lines. If lines are unassigned, they will be excluded from created tickets.",
"payout_methods": {
"commission": "Commission",
"hourly": "Hourly"
},
"payrollclaimedtasks": "These time tickets will be automatically entered to the system as a part of claiming this task. These numbers are calculated using the jobs assigned lines. If lines are unassigned, they will be excluded from created tickets.",
"pmbreak": "PM Break",
"pmshift": "PM Shift",
"shift": "Shift",

View File

@@ -120,8 +120,9 @@
"appointmentinsert": "",
"assignedlinehours": "",
"billdeleted": "",
"billposted": "",
"billmarkforreexport": "",
"billposted": "",
"billupdated": "",
"failedpayment": "",
"jobassignmentchange": "",
"jobassignmentremoved": "",
@@ -136,6 +137,9 @@
"jobintake": "",
"jobinvoiced": "",
"jobioucreated": "",
"joblineupdate": "",
"jobmanualcreate": "",
"jobmanuallineinsert": "",
"jobmodifylbradj": "",
"jobnoteadded": "",
"jobnotedeleted": "",
@@ -151,7 +155,9 @@
"tasks_deleted": "",
"tasks_uncompleted": "",
"tasks_undeleted": "",
"tasks_updated": ""
"tasks_updated": "",
"timeticketcreated": "",
"timeticketupdated": ""
}
},
"billlines": {
@@ -317,14 +323,14 @@
"addtemplate": "",
"newlaborrate": "",
"newsalestaxcode": "",
"save_shop_information": "",
"newstatus": "",
"save_shop_information": "",
"testrender": ""
},
"errors": {
"creatingdefaultview": "",
"duplicate_job_status": "",
"duplicate_insurance_company": "",
"duplicate_job_status": "",
"loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.",
"saving": "",
"task_preset_allocation_exceeded": ""
@@ -364,6 +370,7 @@
"cashierid": "",
"default_journal": "",
"disablebillwip": "",
"disablecontact": "",
"disablecontactvehiclecreation": "",
"dms_acctnumber": "",
"dms_control_override": "",
@@ -421,35 +428,6 @@
"logo_img_path": "",
"logo_img_path_height": "",
"logo_img_path_width": "",
"scoreboard_setup": {
"daily_body_target": "",
"daily_paint_target": "",
"ignore_blocked_days": "",
"last_number_working_days": "",
"production_target_hours": ""
},
"system_settings": {
"auto_email": {
"attach_pdf_to_email": "",
"from_emails": "",
"parts_order_cc": "",
"parts_return_slip_cc": ""
},
"job_costing": {
"paint_hour_split": "",
"paint_materials_hourly_cost_rate": "",
"prep_hour_split": "",
"shop_materials_hourly_cost_rate": "",
"target_touch_time": "",
"use_paint_scale_data": ""
},
"local_media_server": {
"enabled": "",
"http_path": "",
"network_path": "",
"token": ""
}
},
"md_categories": "",
"md_ccc_rates": "",
"md_classes": "",
@@ -457,6 +435,7 @@
"md_email_cc": "",
"md_from_emails": "",
"md_functionality_toggles": {
"enhanced_early_ros": "",
"parts_queue_toggle": ""
},
"md_hour_split": {
@@ -705,6 +684,13 @@
},
"schedule_end_time": "",
"schedule_start_time": "",
"scoreboard_setup": {
"daily_body_target": "",
"daily_paint_target": "",
"ignore_blocked_days": "",
"last_number_working_days": "",
"production_target_hours": ""
},
"shopname": "",
"speedprint": {
"id": "",
@@ -751,6 +737,28 @@
"production_statuses": "",
"ready_statuses": ""
},
"system_settings": {
"auto_email": {
"attach_pdf_to_email": "",
"from_emails": "",
"parts_order_cc": "",
"parts_return_slip_cc": ""
},
"job_costing": {
"paint_hour_split": "",
"paint_materials_hourly_cost_rate": "",
"prep_hour_split": "",
"shop_materials_hourly_cost_rate": "",
"target_touch_time": "",
"use_paint_scale_data": ""
},
"local_media_server": {
"enabled": "",
"http_path": "",
"network_path": "",
"token": ""
}
},
"target_touchtime": "",
"timezone": "",
"tt_allow_post_to_invoiced": "",
@@ -770,6 +778,7 @@
"alljobstatuses": "",
"allopenjobstatuses": "",
"apptcolors": "",
"autoemail": "",
"businessinformation": "",
"checklists": "",
"consent_settings": "",
@@ -777,7 +786,6 @@
"customtemplates": "",
"defaultcostsmapping": "",
"defaultprofitsmapping": "",
"dms_setup": "",
"deliverchecklist": "",
"dms": {
"cdk": {
@@ -792,10 +800,11 @@
"rr_dealerid": "",
"title": ""
},
"dms_setup": "",
"emaillater": "",
"employee_teams": "",
"employee_options": "",
"employee_rates": "",
"employee_teams": "",
"employee_vacation": "",
"employees": "",
"estimators": "",
@@ -806,21 +815,22 @@
"intakechecklist": "",
"intellipay_cash_discount": "",
"job_status_options": "",
"jobcosting": "",
"jobstatuses": "",
"jump_to_section": "",
"laborrates": "",
"licensing": "",
"localmediaserver": "",
"md_parts_scan": "",
"md_ro_guard": "",
"md_ro_guard_options": "",
"md_tasks_presets": "",
"task_preset_options": "",
"md_to_emails": "",
"md_to_emails_emails": "",
"messagingpresets": "",
"notification_options": "",
"notemplatesavailable": "",
"notespresets": "",
"jump_to_section": "",
"notification_options": "",
"notifications": {
"followers": ""
},
@@ -857,9 +867,6 @@
"roguard": {
"title": ""
},
"autoemail": "",
"jobcosting": "",
"localmediaserver": "",
"romepay": "",
"scheduling": "",
"scoreboardsetup": "",
@@ -871,6 +878,7 @@
"ssbuckets": "",
"systemsettings": "",
"task-presets": "",
"task_preset_options": "",
"workingdays": ""
},
"operations": {
@@ -1215,6 +1223,7 @@
"confirmdelete": "",
"doctype": "",
"dragtoupload": "",
"greyscale": "Escala de grises",
"newjobid": "",
"openinexplorer": "",
"optimizedimage": "",
@@ -1345,6 +1354,31 @@
"unique_employee_number": ""
}
},
"esignature": {
"actions": {
"delete": "",
"distribute": "",
"redistribute": "",
"upload_document": "Upload Document for E-Sign",
"view": ""
},
"errors": {
"no_token": "",
"pdf_only": "Only PDF documents can be uploaded for e-signature.",
"upload_title": "Unable to prepare document for e-signature"
},
"fields": {
"completed": "",
"completed_at": "",
"created_at": "",
"external_document_id": "",
"opened": "",
"rejected": "",
"status": "",
"title": "",
"updated_at": ""
}
},
"eula": {
"buttons": {
"accept": "Accept EULA"
@@ -1460,8 +1494,8 @@
"beta": "",
"cancel": "",
"changelog": "",
"click_to_begin": "",
"clear": "",
"click_to_begin": "",
"confirmpassword": "",
"created_at": "",
"date": "",
@@ -1781,6 +1815,7 @@
"actions": {
"addDocuments": "Agregar documentos de trabajo",
"addNote": "Añadir la nota",
"addpayer": "",
"addtopartsqueue": "",
"addtoproduction": "",
"addtoscoreboard": "",
@@ -1957,6 +1992,7 @@
"ded_status": "Estado deducible",
"depreciation_taxes": "Depreciación / Impuestos",
"dms": {
"IsARCustomer": "",
"address": "",
"advisor": "",
"amount": "",
@@ -2307,6 +2343,8 @@
"duplicateconfirm": "",
"emailaudit": "",
"employeeassignments": "",
"esignature_imex": "",
"esignature_rome": "",
"estimatelines": "",
"estimator": "",
"existing_jobs": "Empleos existentes",
@@ -2746,6 +2784,9 @@
"alternate-transport-changed": "",
"bill-posted": "",
"critical-parts-status-changed": "",
"esign-document-completed": "E-Sign Document Completed",
"esign-document-opened": "E-Sign Document Opened",
"esign-document-upload-failed": "E-Sign Document Upload Failed",
"intake-delivery-checklist-completed": "",
"job-added-to-production": "",
"job-assigned-to-me": "",
@@ -3258,6 +3299,7 @@
"information": "",
"layout": "",
"statistics": {
"exclude_suspended": "",
"jobs_in_production": "",
"tasks_in_production": "",
"tasks_in_view": "",
@@ -3749,11 +3791,11 @@
"jobhours": "",
"lunch": "",
"new": "",
"payrollclaimedtasks": "",
"payout_methods": {
"commission": "",
"hourly": ""
},
"payrollclaimedtasks": "",
"pmbreak": "",
"pmshift": "",
"shift": "",

View File

@@ -122,6 +122,7 @@
"billdeleted": "",
"billmarkforreexport": "",
"billposted": "",
"billupdated": "",
"failedpayment": "",
"jobassignmentchange": "",
"jobassignmentremoved": "",
@@ -136,6 +137,9 @@
"jobintake": "",
"jobinvoiced": "",
"jobioucreated": "",
"joblineupdate": "",
"jobmanualcreate": "",
"jobmanuallineinsert": "",
"jobmodifylbradj": "",
"jobnoteadded": "",
"jobnotedeleted": "",
@@ -151,7 +155,9 @@
"tasks_deleted": "",
"tasks_uncompleted": "",
"tasks_undeleted": "",
"tasks_updated": ""
"tasks_updated": "",
"timeticketcreated": "",
"timeticketupdated": ""
}
},
"billlines": {
@@ -317,14 +323,14 @@
"addtemplate": "",
"newlaborrate": "",
"newsalestaxcode": "",
"save_shop_information": "",
"newstatus": "",
"save_shop_information": "",
"testrender": ""
},
"errors": {
"creatingdefaultview": "",
"duplicate_job_status": "",
"duplicate_insurance_company": "",
"duplicate_job_status": "",
"loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.",
"saving": "",
"task_preset_allocation_exceeded": ""
@@ -364,6 +370,7 @@
"cashierid": "",
"default_journal": "",
"disablebillwip": "",
"disablecontact": "",
"disablecontactvehiclecreation": "",
"dms_acctnumber": "",
"dms_control_override": "",
@@ -421,35 +428,6 @@
"logo_img_path": "",
"logo_img_path_height": "",
"logo_img_path_width": "",
"scoreboard_setup": {
"daily_body_target": "",
"daily_paint_target": "",
"ignore_blocked_days": "",
"last_number_working_days": "",
"production_target_hours": ""
},
"system_settings": {
"auto_email": {
"attach_pdf_to_email": "",
"from_emails": "",
"parts_order_cc": "",
"parts_return_slip_cc": ""
},
"job_costing": {
"paint_hour_split": "",
"paint_materials_hourly_cost_rate": "",
"prep_hour_split": "",
"shop_materials_hourly_cost_rate": "",
"target_touch_time": "",
"use_paint_scale_data": ""
},
"local_media_server": {
"enabled": "",
"http_path": "",
"network_path": "",
"token": ""
}
},
"md_categories": "",
"md_ccc_rates": "",
"md_classes": "",
@@ -457,6 +435,7 @@
"md_email_cc": "",
"md_from_emails": "",
"md_functionality_toggles": {
"enhanced_early_ros": "",
"parts_queue_toggle": ""
},
"md_hour_split": {
@@ -705,6 +684,13 @@
},
"schedule_end_time": "",
"schedule_start_time": "",
"scoreboard_setup": {
"daily_body_target": "",
"daily_paint_target": "",
"ignore_blocked_days": "",
"last_number_working_days": "",
"production_target_hours": ""
},
"shopname": "",
"speedprint": {
"id": "",
@@ -751,6 +737,28 @@
"production_statuses": "",
"ready_statuses": ""
},
"system_settings": {
"auto_email": {
"attach_pdf_to_email": "",
"from_emails": "",
"parts_order_cc": "",
"parts_return_slip_cc": ""
},
"job_costing": {
"paint_hour_split": "",
"paint_materials_hourly_cost_rate": "",
"prep_hour_split": "",
"shop_materials_hourly_cost_rate": "",
"target_touch_time": "",
"use_paint_scale_data": ""
},
"local_media_server": {
"enabled": "",
"http_path": "",
"network_path": "",
"token": ""
}
},
"target_touchtime": "",
"timezone": "",
"tt_allow_post_to_invoiced": "",
@@ -770,6 +778,7 @@
"alljobstatuses": "",
"allopenjobstatuses": "",
"apptcolors": "",
"autoemail": "",
"businessinformation": "",
"checklists": "",
"consent_settings": "",
@@ -777,7 +786,6 @@
"customtemplates": "",
"defaultcostsmapping": "",
"defaultprofitsmapping": "",
"dms_setup": "",
"deliverchecklist": "",
"dms": {
"cdk": {
@@ -792,10 +800,11 @@
"rr_dealerid": "",
"title": ""
},
"dms_setup": "",
"emaillater": "",
"employee_teams": "",
"employee_options": "",
"employee_rates": "",
"employee_teams": "",
"employee_vacation": "",
"employees": "",
"estimators": "",
@@ -806,21 +815,22 @@
"intakechecklist": "",
"intellipay_cash_discount": "",
"job_status_options": "",
"jobcosting": "",
"jobstatuses": "",
"jump_to_section": "",
"laborrates": "",
"licensing": "",
"localmediaserver": "",
"md_parts_scan": "",
"md_ro_guard": "",
"md_ro_guard_options": "",
"md_tasks_presets": "",
"task_preset_options": "",
"md_to_emails": "",
"md_to_emails_emails": "",
"messagingpresets": "",
"notification_options": "",
"notemplatesavailable": "",
"notespresets": "",
"jump_to_section": "",
"notification_options": "",
"notifications": {
"followers": ""
},
@@ -857,9 +867,6 @@
"roguard": {
"title": ""
},
"autoemail": "",
"jobcosting": "",
"localmediaserver": "",
"romepay": "",
"scheduling": "",
"scoreboardsetup": "",
@@ -871,6 +878,7 @@
"ssbuckets": "",
"systemsettings": "",
"task-presets": "",
"task_preset_options": "",
"workingdays": ""
},
"operations": {
@@ -1215,6 +1223,7 @@
"confirmdelete": "",
"doctype": "",
"dragtoupload": "",
"greyscale": "Niveaux de gris",
"newjobid": "",
"openinexplorer": "",
"optimizedimage": "",
@@ -1345,6 +1354,31 @@
"unique_employee_number": ""
}
},
"esignature": {
"actions": {
"delete": "",
"distribute": "",
"redistribute": "",
"upload_document": "Upload Document for E-Sign",
"view": ""
},
"errors": {
"no_token": "",
"pdf_only": "Only PDF documents can be uploaded for e-signature.",
"upload_title": "Unable to prepare document for e-signature"
},
"fields": {
"completed": "",
"completed_at": "",
"created_at": "",
"external_document_id": "",
"opened": "",
"rejected": "",
"status": "",
"title": "",
"updated_at": ""
}
},
"eula": {
"buttons": {
"accept": "Accept EULA"
@@ -1460,8 +1494,8 @@
"beta": "",
"cancel": "",
"changelog": "",
"click_to_begin": "",
"clear": "",
"click_to_begin": "",
"confirmpassword": "",
"created_at": "",
"date": "",
@@ -1781,6 +1815,7 @@
"actions": {
"addDocuments": "Ajouter des documents de travail",
"addNote": "Ajouter une note",
"addpayer": "",
"addtopartsqueue": "",
"addtoproduction": "",
"addtoscoreboard": "",
@@ -1957,6 +1992,7 @@
"ded_status": "Statut de franchise",
"depreciation_taxes": "Amortissement / taxes",
"dms": {
"IsARCustomer": "",
"address": "",
"advisor": "",
"amount": "",
@@ -2307,6 +2343,8 @@
"duplicateconfirm": "",
"emailaudit": "",
"employeeassignments": "",
"esignature_imex": "",
"esignature_rome": "",
"estimatelines": "",
"estimator": "",
"existing_jobs": "Emplois existants",
@@ -2746,6 +2784,9 @@
"alternate-transport-changed": "",
"bill-posted": "",
"critical-parts-status-changed": "",
"esign-document-completed": "E-Sign Document Completed",
"esign-document-opened": "E-Sign Document Opened",
"esign-document-upload-failed": "E-Sign Document Upload Failed",
"intake-delivery-checklist-completed": "",
"job-added-to-production": "",
"job-assigned-to-me": "",
@@ -3258,6 +3299,7 @@
"information": "",
"layout": "",
"statistics": {
"exclude_suspended": "",
"jobs_in_production": "",
"tasks_in_production": "",
"tasks_in_view": "",
@@ -3749,11 +3791,11 @@
"jobhours": "",
"lunch": "",
"new": "",
"payrollclaimedtasks": "",
"payout_methods": {
"commission": "",
"hourly": ""
},
"payrollclaimedtasks": "",
"pmbreak": "",
"pmshift": "",
"shift": "",

View File

@@ -1,5 +1,6 @@
import i18n from "i18next";
//import { store } from "../redux/store";
import { DMS_MAP } from "./dmsUtils";
import InstanceRenderManager from "./instanceRenderMgr";
export const EmailSettings = {
@@ -570,7 +571,8 @@ export const TemplateList = (type, context) => {
key: "dms_posting_sheet",
disabled: false,
group: "financial",
dms: true
dms: true,
excludedDmsModes: [DMS_MAP.reynolds]
},
worksheet_sorted_by_team: {
title: i18n.t("printcenter.jobs.worksheet_sorted_by_team"),

View File

@@ -0,0 +1,7 @@
export const hasDocumensoApiKey = (bodyshop) => {
if (typeof bodyshop?.documenso_api_key === "string") {
return bodyshop.documenso_api_key.trim().length > 0;
}
return Boolean(bodyshop?.documenso_api_key);
};

View File

@@ -2,7 +2,7 @@
* @description This file contains the scenarios for job notifications.
* @type {string[]}
*/
const notificationScenarios = [
const baseNotificationScenarios = [
"job-assigned-to-me",
"bill-posted",
"critical-parts-status-changed",
@@ -20,4 +20,26 @@ const notificationScenarios = [
// "supplement-imported", // Disabled for now
];
export { notificationScenarios };
const esignNotificationScenarios = [
"esign-document-opened",
"esign-document-completed",
"esign-document-upload-failed"
];
const notificationScenarios = [...baseNotificationScenarios, ...esignNotificationScenarios];
const getNotificationScenarios = ({ includeEsign = true } = {}) =>
includeEsign ? notificationScenarios : baseNotificationScenarios;
/**
* Default channel preferences for e-sign document notifications. By default, all e-sign related notifications will be
* sent via the app, but not via email or FCM. These defaults can be overridden by user preferences.
* @type {{"esign-document-opened": {app: boolean, email: boolean, fcm: boolean}, "esign-document-completed": {app: boolean, email: boolean, fcm: boolean}, "esign-document-upload-failed": {app: boolean, email: boolean, fcm: boolean}}}
*/
const notificationScenarioDefaults = {
"esign-document-opened": { app: true, email: false, fcm: false },
"esign-document-completed": { app: true, email: false, fcm: false },
"esign-document-upload-failed": { app: true, email: false, fcm: false }
};
export { esignNotificationScenarios, getNotificationScenarios, notificationScenarios, notificationScenarioDefaults };

View File

@@ -0,0 +1,28 @@
export const replaceAccents = (str) => {
// Verifies if the String has accents and replace them
if (str.search(/[\xC0-\xFF]/g) > -1) {
str = str
.replace(/[\xC0-\xC5]/g, "A")
.replace(/[\xC6]/g, "AE")
.replace(/[\xC7]/g, "C")
.replace(/[\xC8-\xCB]/g, "E")
.replace(/[\xCC-\xCF]/g, "I")
.replace(/[\xD0]/g, "D")
.replace(/[\xD1]/g, "N")
.replace(/[\xD2-\xD6\xD8]/g, "O")
.replace(/[\xD9-\xDC]/g, "U")
.replace(/[\xDD]/g, "Y")
.replace(/[\xDE]/g, "P")
.replace(/[\xE0-\xE5]/g, "a")
.replace(/[\xE6]/g, "ae")
.replace(/[\xE7]/g, "c")
.replace(/[\xE8-\xEB]/g, "e")
.replace(/[\xEC-\xEF]/g, "i")
.replace(/[\xF1]/g, "n")
.replace(/[\xF2-\xF6\xF8]/g, "o")
.replace(/[\xF9-\xFC]/g, "u")
.replace(/[\xFE]/g, "p")
.replace(/[\xFD\xFF]/g, "y");
}
return str;
};

View File

@@ -0,0 +1,24 @@
-----BEGIN CERTIFICATE-----
MIID9zCCAt+gAwIBAgIUTB4OhIqfXvT0mBKHwYAwDPq79ygwDQYJKoZIhvcNAQEL
BQAwgYoxCzAJBgNVBAYTAkNBMQswCQYDVQQIDAJCQzESMBAGA1UEBwwJVmFuY291
dmVyMRowGAYDVQQKDBFJbUVYIFN5c3RlbXMgSW5jLjEXMBUGA1UEAwwOaW1leHN5
c3RlbXMuY2ExJTAjBgkqhkiG9w0BCQEWFmNvbnRhY3RAaW1leHN5c3RlbXMuY2Ew
HhcNMjYwNDEzMjAxMDIzWhcNMzYwNDEwMjAxMDIzWjCBijELMAkGA1UEBhMCQ0Ex
CzAJBgNVBAgMAkJDMRIwEAYDVQQHDAlWYW5jb3V2ZXIxGjAYBgNVBAoMEUltRVgg
U3lzdGVtcyBJbmMuMRcwFQYDVQQDDA5pbWV4c3lzdGVtcy5jYTElMCMGCSqGSIb3
DQEJARYWY29udGFjdEBpbWV4c3lzdGVtcy5jYTCCASIwDQYJKoZIhvcNAQEBBQAD
ggEPADCCAQoCggEBAPE+5bcnfYsMyLzJr50bzpHHP8I+cdSkvu7lwGysPZCCxi4Z
vkIDq4Q5xDa3ZZCeNZ9feELqm9ZjWpnaZj4CMbXMDpIucZHQJC9USCGavYhzNYu2
G3IU7D834jd8GkwGMQuXkGiuQmQssIZIKfX+MaZ0KKrh8gJbxXZOfCp3fdYOnFPq
BFCR0N/gTbeRboq36dG4vo1FanDLGroMS7FycGjyUTQv3CTWkGAOAPGQVrGZgvYM
DtFr+7M2J/KCbUMobK0uc1scAjLgetXknzVPU3qA66F3Hi7oWykoFX8m9oX/OJnK
/Gt8rIjRMOyQSK7dKT7qXCxgQVQnqHbyUCX4WUkCAwEAAaNTMFEwHQYDVR0OBBYE
FIRKLjeI+adC7yNg6cSDj72Kej11MB8GA1UdIwQYMBaAFIRKLjeI+adC7yNg6cSD
j72Kej11MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAAHCSjlG
bo5miEfisKffPyfzufBIhOLLORasuFQ3gVKBU32JytuoflABfcqy3prgZxbFLMB2
fDcSImKuOtt79OMeMlA+ptfkWuOpFMqL2j6BilzjJ/MAlPAZlZmmuLh/fPj3lbMD
QQds/YhSmZcTdRX8seQslnYq1AT7629BDbpCjjL3pRkntnePR7u8tgb28Pm8Vl3S
uCnGS/mMxrS/7z+QnaDi1N/nyIwa2bQtGmsoMn+CzuUUjyMD4TYbdUJv+fca8/tR
zezNEHcpBCKGGgZRowhifJwEoel0M1iEo8UYy5eFPDF8CoRGRIH7QSaduCfnej06
KLtevL/vyhUpTMA=
-----END CERTIFICATE-----

Binary file not shown.

View File

@@ -0,0 +1,72 @@
name: documenso-production
services:
database:
image: postgres:15
environment:
- POSTGRES_USER=${POSTGRES_USER:?err}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
- POSTGRES_DB=${POSTGRES_DB:?err}
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
interval: 10s
timeout: 5s
retries: 5
volumes:
- database:/var/lib/postgresql/data
documenso:
image: documenso/documenso:latest
depends_on:
database:
condition: service_healthy
environment:
- PORT=${PORT:-3000}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:?err}
- NEXT_PRIVATE_ENCRYPTION_KEY=${NEXT_PRIVATE_ENCRYPTION_KEY:?err}
- NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=${NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY:?err}
- NEXT_PRIVATE_GOOGLE_CLIENT_ID=${NEXT_PRIVATE_GOOGLE_CLIENT_ID}
- NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=${NEXT_PRIVATE_GOOGLE_CLIENT_SECRET}
- NEXT_PUBLIC_WEBAPP_URL=${NEXT_PUBLIC_WEBAPP_URL:?err}
- NEXT_PRIVATE_INTERNAL_WEBAPP_URL=${NEXT_PRIVATE_INTERNAL_WEBAPP_URL:-http://localhost:$PORT}
- NEXT_PRIVATE_DATABASE_URL=${NEXT_PRIVATE_DATABASE_URL:?err}
- NEXT_PRIVATE_DIRECT_DATABASE_URL=${NEXT_PRIVATE_DIRECT_DATABASE_URL:-${NEXT_PRIVATE_DATABASE_URL}}
- NEXT_PUBLIC_UPLOAD_TRANSPORT=${NEXT_PUBLIC_UPLOAD_TRANSPORT:-database}
- NEXT_PRIVATE_UPLOAD_ENDPOINT=${NEXT_PRIVATE_UPLOAD_ENDPOINT}
- NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE=${NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE}
- NEXT_PRIVATE_UPLOAD_REGION=${NEXT_PRIVATE_UPLOAD_REGION}
- NEXT_PRIVATE_UPLOAD_BUCKET=${NEXT_PRIVATE_UPLOAD_BUCKET}
- NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=${NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID}
- NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY=${NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY}
- NEXT_PRIVATE_SMTP_TRANSPORT=${NEXT_PRIVATE_SMTP_TRANSPORT:?err}
- NEXT_PRIVATE_SMTP_HOST=${NEXT_PRIVATE_SMTP_HOST}
- NEXT_PRIVATE_SMTP_PORT=${NEXT_PRIVATE_SMTP_PORT}
- NEXT_PRIVATE_SMTP_USERNAME=${NEXT_PRIVATE_SMTP_USERNAME}
- NEXT_PRIVATE_SMTP_PASSWORD=${NEXT_PRIVATE_SMTP_PASSWORD}
- NEXT_PRIVATE_SMTP_APIKEY_USER=${NEXT_PRIVATE_SMTP_APIKEY_USER}
- NEXT_PRIVATE_SMTP_APIKEY=${NEXT_PRIVATE_SMTP_APIKEY}
- NEXT_PRIVATE_SMTP_SECURE=${NEXT_PRIVATE_SMTP_SECURE}
- NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS=${NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS}
- NEXT_PRIVATE_SMTP_FROM_NAME=${NEXT_PRIVATE_SMTP_FROM_NAME:?err}
- NEXT_PRIVATE_SMTP_FROM_ADDRESS=${NEXT_PRIVATE_SMTP_FROM_ADDRESS:?err}
- NEXT_PRIVATE_SMTP_SERVICE=${NEXT_PRIVATE_SMTP_SERVICE}
- NEXT_PRIVATE_RESEND_API_KEY=${NEXT_PRIVATE_RESEND_API_KEY}
- NEXT_PRIVATE_MAILCHANNELS_API_KEY=${NEXT_PRIVATE_MAILCHANNELS_API_KEY}
- NEXT_PRIVATE_MAILCHANNELS_ENDPOINT=${NEXT_PRIVATE_MAILCHANNELS_ENDPOINT}
- NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN=${NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN}
- NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR=${NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR}
- NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=${NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY}
- NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=${NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_DISABLE_SIGNUP=${NEXT_PUBLIC_DISABLE_SIGNUP}
- NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=${NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS}
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12}
- NEXT_PRIVATE_SIGNING_PASSPHRASE=${NEXT_PRIVATE_SIGNING_PASSPHRASE}
- NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=${NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS}
ports:
- ${PORT:-3000}:${PORT:-3000}
volumes:
- /opt/documenso/cert.p12:/opt/documenso/cert.p12:ro
volumes:
database:

45
documenso/terraform/.terraform.lock.hcl generated Normal file
View File

@@ -0,0 +1,45 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "6.38.0"
constraints = "~> 6.0"
hashes = [
"h1:RDoKIzXmt7H1mNFcNIyRT+nA/gTJyO3+iW9QGN5I2eQ=",
"zh:143f118ae71059a7a7026c6b950da23fef04a06e2362ffa688bef75e43e869ed",
"zh:29ee220a017306effd877e1280f8b2934dc957e16e0e72ca0222e5514d0db522",
"zh:3a31baabf7aea7aa7669f5a3d76f3445e0e6cce5e9aea0279992765c0df12aee",
"zh:4c1908e62040dbc9901d4426ffb253f53e5dae9e3e1a9125311291ee265c8d8c",
"zh:550f4789f5f5b00e16118d4c17770be3ef4535d6b6928af1cf91ebd30f2c263b",
"zh:6537b7b70bf2c127771b0b84e4b726c834d10666b6104f017edae50c67ebae37",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:af2f9cea0c8bdf5b2a2391f2d179a946c117196f7c829b919673cae3b71d2943",
"zh:c53ffa685381aa4e73158fd9f529239f95938dea330e7aca0b32e7b2a1210432",
"zh:d0995e1d64a7ec8bbc79fc3fbec3749f989e07f211a318705c37cd6a7c7d19e4",
"zh:d2348ffcffc1282983d7a5838dd5d61f372152fe6c0d10868cd6473352318750",
"zh:e449312efb73e4747165e689302a68a1df8ba5755e7f59097069acf82c94f011",
"zh:ec3a538d264ef79380e56fdf107ffb6c0446814f07fc5890c36855fe1e03196b",
"zh:f441e69699b22e32c96a8cdd3bbe694ed302c0dcfe867cd9bd683a16df362714",
"zh:f6f8eaa605ff902234d7e9bdab4fda977185fce14f8576f7b622c914c7d98008",
]
}
provider "registry.terraform.io/hashicorp/random" {
version = "3.8.1"
constraints = "~> 3.6"
hashes = [
"h1:u8AKlWVDTH5r9YLSeswoVEjiY72Rt4/ch7U+61ZDkiQ=",
"zh:08dd03b918c7b55713026037c5400c48af5b9f468f483463321bd18e17b907b4",
"zh:0eee654a5542dc1d41920bbf2419032d6f0d5625b03bd81339e5b33394a3e0ae",
"zh:229665ddf060aa0ed315597908483eee5b818a17d09b6417a0f52fd9405c4f57",
"zh:2469d2e48f28076254a2a3fc327f184914566d9e40c5780b8d96ebf7205f8bc0",
"zh:37d7eb334d9561f335e748280f5535a384a88675af9a9eac439d4cfd663bcb66",
"zh:741101426a2f2c52dee37122f0f4a2f2d6af6d852cb1db634480a86398fa3511",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:a902473f08ef8df62cfe6116bd6c157070a93f66622384300de235a533e9d4a9",
"zh:b85c511a23e57a2147355932b3b6dce2a11e856b941165793a0c3d7578d94d05",
"zh:c5172226d18eaac95b1daac80172287b69d4ce32750c82ad77fa0768be4ea4b8",
"zh:dab4434dba34aad569b0bc243c2d3f3ff86dd7740def373f2a49816bd2ff819b",
"zh:f49fd62aa8c5525a5c17abd51e27ca5e213881d58882fd42fec4a545b53c9699",
]
}

View File

@@ -0,0 +1,60 @@
# Documenso on AWS
This Terraform stack deploys Documenso to AWS in `ca-central-1` using:
- ECS Fargate for the application tier
- RDS PostgreSQL for the database tier
- S3 for document uploads and signed PDFs
- Application Load Balancer with ACM-managed TLS
- Route53 DNS for `esignature.imex.online`
- Optional SES domain identity and DKIM management for outbound email
- Secrets Manager for generated application secrets, SMTP credentials, and the optional Documenso signing certificate
- AWS WAF with a basic managed rule set, rate limiting, and an allowlist for trusted IPv4 CIDRs
- CloudWatch alarms for ALB, ECS, and RDS health indicators
## Why this shape
This is the most practical fit for your Docker Compose workload if you want a balance of cost efficiency, managed operations, and scaling:
- Fargate gives you horizontal scaling without managing EC2 hosts.
- RDS PostgreSQL is simpler and cheaper than Aurora for a single Documenso workload.
- S3-backed uploads are better for production scale and keep document growth out of PostgreSQL.
- The database stays private; the ALB is public.
- The ECS tasks run in public subnets to avoid a NAT gateway charge. Inbound access is still restricted to the ALB security group.
- HTTPS is terminated by the ALB using ACM. The Documenso self-signed `.p12` certificate is separate and is used for document signing, not browser TLS.
## Files
- `main.tf`: core infrastructure
- `variables.tf`: configurable inputs
- `outputs.tf`: useful deployment outputs
- `terraform.tfvars.example`: example input values
## Assumptions built into this stack
1. Your DNS for `imex.online` is hosted in Route53.
2. You want Multi-AZ RDS enabled from the start for database availability.
3. You are comfortable starting with `documenso/documenso:latest`. For repeatable deployments, pin a version or digest after your first rollout.
4. You will provide SES SMTP credentials. Terraform does not derive SMTP passwords for you.
5. SES identity and DKIM might already be managed outside this stack. By default, this Terraform does not attempt to create them.
6. You will provide a base64-encoded PKCS#12 signing certificate and passphrase if you want document signing enabled immediately. This stack injects those values through Secrets Manager instead of mounting a host file.
7. You are comfortable with Terraform creating a dedicated IAM user and access key for Documenso S3 uploads because Documenso documents explicit S3 credentials for the upload backend.
8. You want Terraform destroy protection enabled for both the database and the uploads bucket.
## Deploy
1. Copy `terraform.tfvars.example` to `terraform.tfvars` and fill in the SMTP values.
2. If you want Documenso signing enabled, add `signing_certificate_base64` and `signing_certificate_passphrase`.
3. Optionally set `upload_bucket_name` if you want a specific S3 bucket name.
4. Set `manage_ses_resources = true` only if you want this stack to own SES identity verification and DKIM records.
5. Set `waf_bypass_ipv4_cidrs` with any public `/32` addresses that should bypass WAF inspection. The VPC CIDR is already allowlisted automatically.
6. Run `terraform init`.
7. Run `terraform plan`.
8. Run `terraform apply`.
## Recommended first production adjustments
1. Pin the Documenso image to a tested version or digest.
2. Wire `alarm_actions` to an SNS topic, PagerDuty bridge, or your on-call system so alarms notify someone.
3. Expand the WAF rule set if you need more aggressive filtering later.
4. Add CloudWatch alarms on ECS 5xx errors, ALB target health, and RDS CPU/storage.

1052
documenso/terraform/main.tf Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
output "application_url" {
description = "Public URL for the Documenso deployment."
value = "https://${var.domain_name}"
}
output "load_balancer_dns_name" {
description = "DNS name assigned to the application load balancer."
value = aws_lb.this.dns_name
}
output "database_endpoint" {
description = "RDS PostgreSQL endpoint for the application."
value = aws_db_instance.postgres.address
}
output "postgres_engine_version" {
description = "Resolved PostgreSQL engine version deployed to RDS."
value = aws_db_instance.postgres.engine_version
}
output "ecs_cluster_name" {
description = "ECS cluster name running the Documenso service."
value = aws_ecs_cluster.this.name
}
output "secrets_manager_secret_name" {
description = "Secrets Manager secret that stores generated and supplied application secrets."
value = aws_secretsmanager_secret.app.name
}
output "ses_identity_domain" {
description = "SES domain used for outbound mail."
value = local.ses_domain
}
output "upload_bucket_name" {
description = "S3 bucket used for Documenso uploads."
value = aws_s3_bucket.uploads.bucket
}
output "waf_web_acl_arn" {
description = "ARN of the WAF web ACL attached to the ALB."
value = aws_wafv2_web_acl.this.arn
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,24 @@
aws_region = "ca-central-1"
domain_name = "esignature.imex.online"
hosted_zone_name = "imex.online"
documenso_image = "documenso/documenso:latest"
smtp_username = "AKIA2MRSPON3O6PRVUPE"
smtp_password = "pw"
smtp_from_address = "no-reply@imex.online"
manage_ses_resources = false
ses_identity_domain = "imex.online"
app_secret_name = "documenso/esignature-imex-online/app"
# signing_certificate_base64 = "MII...base64-encoded-p12..."
# signing_certificate_passphrase = "replace-with-your-p12-passphrase"
# upload_bucket_name = "esignature-imex-online-documenso"
# Optional tuning
# desired_count = 2
# max_count = 6
waf_bypass_ipv4_cidrs = ["203.0.113.10/32"]
db_instance_class = "db.t4g.micro"
db_publicly_accessible = true
db_allowed_cidrs = ["64.46.30.40/32"]
disable_signup = false
# allowed_signup_domains = "imex.online"
# alarm_actions = ["arn:aws:sns:ca-central-1:123456789012:ops-alerts"]

View File

@@ -0,0 +1,318 @@
variable "aws_region" {
description = "AWS region for the deployment."
type = string
default = "ca-central-1"
}
variable "project_name" {
description = "Logical name used to prefix created resources."
type = string
default = "documenso"
}
variable "domain_name" {
description = "Fully qualified domain name for the application."
type = string
default = "esignature.imex.online"
}
variable "hosted_zone_name" {
description = "Public Route53 hosted zone that contains the application hostname."
type = string
default = "imex.online"
}
variable "ses_identity_domain" {
description = "Domain used for SES. Defaults to the hosted zone when null. If manage_ses_resources is false, this is informational and used only for outputs/documentation."
type = string
default = null
}
variable "manage_ses_resources" {
description = "Whether this Terraform stack should create and manage the SES domain identity, verification record, and DKIM records. Disable this when SES is already configured elsewhere."
type = bool
default = false
}
variable "documenso_image" {
description = "Container image for Documenso. Default keeps you on the latest published image."
type = string
default = "documenso/documenso:latest"
}
variable "app_port" {
description = "Container port exposed by Documenso."
type = number
default = 3000
}
variable "upload_bucket_name" {
description = "Optional S3 bucket name for Documenso uploads. If null, Terraform generates a globally unique name based on account and region."
type = string
default = null
}
variable "s3_versioning_enabled" {
description = "Enable S3 object versioning for uploaded documents."
type = bool
default = true
}
variable "document_size_upload_limit_mb" {
description = "Upload size limit shown in the Documenso UI, in MB."
type = number
default = 10
}
variable "vpc_cidr" {
description = "CIDR block used for the VPC."
type = string
default = "10.42.0.0/16"
}
variable "fargate_cpu" {
description = "Fargate CPU units for the task."
type = number
default = 512
}
variable "fargate_memory" {
description = "Fargate memory in MiB for the task."
type = number
default = 1024
}
variable "desired_count" {
description = "Initial number of running Documenso tasks."
type = number
default = 1
}
variable "min_count" {
description = "Minimum number of tasks for autoscaling."
type = number
default = 1
}
variable "max_count" {
description = "Maximum number of tasks for autoscaling."
type = number
default = 4
}
variable "cpu_target_utilization" {
description = "Target average CPU utilization for ECS autoscaling."
type = number
default = 65
}
variable "memory_target_utilization" {
description = "Target average memory utilization for ECS autoscaling."
type = number
default = 75
}
variable "postgres_major_version" {
description = "Preferred PostgreSQL major version. Terraform resolves the latest matching minor release supported by AWS."
type = string
default = "17"
}
variable "db_name" {
description = "Initial PostgreSQL database name."
type = string
default = "documenso"
}
variable "db_username" {
description = "Master PostgreSQL username for the application."
type = string
default = "documenso"
}
variable "db_instance_class" {
description = "RDS instance class. Graviton classes are usually the best cost/performance option for Postgres."
type = string
default = "db.t4g.small"
}
variable "db_allocated_storage" {
description = "Initial allocated storage in GiB."
type = number
default = 20
}
variable "db_max_allocated_storage" {
description = "Maximum autoscaled storage in GiB."
type = number
default = 100
}
variable "db_backup_retention_days" {
description = "How many days of automated backups to retain."
type = number
default = 7
}
variable "db_multi_az" {
description = "Enable Multi-AZ for higher database availability at higher cost."
type = bool
default = true
}
variable "db_deletion_protection" {
description = "Protect the database from accidental deletion."
type = bool
default = true
}
variable "db_final_snapshot_on_destroy" {
description = "Create a final snapshot if the database is destroyed."
type = bool
default = true
}
variable "db_publicly_accessible" {
description = "Whether the RDS instance should have a public endpoint. Requires database subnets with a route to the internet gateway."
type = bool
default = false
}
variable "db_allowed_cidrs" {
description = "IPv4 CIDR blocks allowed to connect directly to PostgreSQL. Leave empty to disable direct public access."
type = list(string)
default = []
}
variable "disable_signup" {
description = "Disable public signup in Documenso."
type = bool
default = true
}
variable "allowed_signup_domains" {
description = "Optional comma-separated list of allowed email domains when signup is enabled."
type = string
default = ""
}
variable "smtp_port" {
description = "SES SMTP endpoint port."
type = number
default = 587
}
variable "smtp_secure" {
description = "Whether to use SMTPS. Keep false for SES on port 587 with STARTTLS."
type = bool
default = false
}
variable "smtp_unsafe_ignore_tls" {
description = "Whether the application should ignore TLS issues when sending mail."
type = bool
default = false
}
variable "smtp_username" {
description = "SES SMTP username."
type = string
sensitive = true
}
variable "smtp_password" {
description = "SES SMTP password."
type = string
sensitive = true
}
variable "smtp_from_name" {
description = "Display name used in outbound email."
type = string
default = "ImEX Sign"
}
variable "smtp_from_address" {
description = "Verified sender email address for SES."
type = string
}
variable "signing_certificate_base64" {
description = "Base64-encoded PKCS#12 signing certificate contents for Documenso. Leave empty to omit certificate injection."
type = string
default = ""
sensitive = true
}
variable "signing_certificate_passphrase" {
description = "Passphrase for the Documenso signing certificate. Leave empty to omit it."
type = string
default = ""
sensitive = true
}
variable "app_secret_name" {
description = "Secrets Manager secret name used for Documenso application secrets. Set this if a previous secret with the default name is pending deletion."
type = string
default = null
}
variable "tags" {
description = "Additional tags applied to all supported resources."
type = map(string)
default = {}
}
variable "waf_rate_limit" {
description = "Maximum requests per 5-minute window from a single IP before WAF blocks it."
type = number
default = 2000
}
variable "waf_bypass_ipv4_cidrs" {
description = "Additional IPv4 CIDR blocks that bypass the WAF. The VPC CIDR is always included automatically."
type = list(string)
default = []
}
variable "alarm_actions" {
description = "Optional list of SNS topic ARNs or other alarm actions to invoke when CloudWatch alarms fire."
type = list(string)
default = []
}
variable "alb_5xx_alarm_threshold" {
description = "Threshold for ALB 5xx count over a 5-minute period."
type = number
default = 10
}
variable "ecs_cpu_alarm_threshold" {
description = "Threshold for average ECS CPU utilization alarm."
type = number
default = 85
}
variable "ecs_memory_alarm_threshold" {
description = "Threshold for average ECS memory utilization alarm."
type = number
default = 85
}
variable "rds_cpu_alarm_threshold" {
description = "Threshold for average RDS CPU utilization alarm."
type = number
default = 80
}
variable "rds_free_storage_alarm_threshold_bytes" {
description = "Alarm threshold for low RDS free storage, in bytes."
type = number
default = 5368709120
}
variable "documenso_license_key" {
description = "Documenso license key. Not required for the free community edition, but required for enterprise features and support."
type = string
default = ""
}

View File

@@ -956,6 +956,7 @@
- created_at
- default_adjustment_rate
- deliverchecklist
- documenso_api_key
- email
- enforce_class
- enforce_conversion_category
@@ -1164,6 +1165,7 @@
- notification_followers
- state
- md_order_statuses
- md_ro_statuses
retry_conf:
interval_sec: 10
num_retries: 0
@@ -1184,7 +1186,8 @@
"new": {
"id": {{$body.event.data.new.id}},
"shopname": {{$body.event.data.new.shopname}},
"md_order_statuses": {{$body.event.data.new.md_order_statuses}}
"md_order_statuses": {{$body.event.data.new.md_order_statuses}},
"md_ro_statuses": {{$body.event.data.new.md_ro_statuses}}
}
},
"op": {{$body.event.op}},
@@ -1891,6 +1894,14 @@
- name: job
using:
foreign_key_constraint_on: jobid
array_relationships:
- name: esignature_documents
using:
foreign_key_constraint_on:
column: documentid
table:
name: esignature_documents
schema: public
insert_permissions:
- role: user
permission:
@@ -2566,6 +2577,101 @@
_eq: X-Hasura-User-Id
- active:
_eq: true
- table:
name: esignature_documents
schema: public
object_relationships:
- name: document
using:
foreign_key_constraint_on: documentid
- name: job
using:
foreign_key_constraint_on: jobid
insert_permissions:
- role: user
permission:
check:
job:
bodyshop:
associations:
_and:
- active:
_eq: true
- user:
authid:
_eq: X-Hasura-User-Id
columns:
- completed
- documentid
- external_document_id
- jobid
- message
- opened
- recipients
- rejected
- status
- subject
- title
comment: ""
select_permissions:
- role: user
permission:
columns:
- completed
- completed_at
- created_at
- documentid
- external_document_id
- id
- jobid
- message
- opened
- recipients
- rejected
- status
- subject
- title
- updated_at
filter:
job:
bodyshop:
associations:
_and:
- active:
_eq: true
- user:
authid:
_eq: X-Hasura-User-Id
comment: ""
update_permissions:
- role: user
permission:
columns:
- completed
- completed_at
- created_at
- documentid
- external_document_id
- message
- opened
- recipients
- rejected
- status
- subject
- title
- updated_at
filter:
job:
bodyshop:
associations:
_and:
- active:
_eq: true
- user:
authid:
_eq: X-Hasura-User-Id
check: null
comment: ""
- table:
name: eula_acceptances
schema: public
@@ -3464,6 +3570,13 @@
table:
name: email_audit_trail
schema: public
- name: esignature_documents
using:
foreign_key_constraint_on:
column: jobid
table:
name: esignature_documents
schema: public
- name: exportlogs
using:
foreign_key_constraint_on:

View File

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

View File

@@ -0,0 +1,18 @@
CREATE TABLE "public"."esignature_documents" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now(), "external_document_id" text NOT NULL, "jobid" uuid NOT NULL, "status" text NOT NULL, "recipients" jsonb[] NOT NULL, "title" text NOT NULL, "subject" text NOT NULL, "message" text NOT NULL, "viewed" boolean NOT NULL DEFAULT false, "completed" boolean NOT NULL DEFAULT false, "documentid" uuid, "rejected" boolean NOT NULL DEFAULT false, "opened" boolean NOT NULL DEFAULT false, PRIMARY KEY ("id") , FOREIGN KEY ("jobid") REFERENCES "public"."jobs"("id") ON UPDATE restrict ON DELETE restrict);COMMENT ON TABLE "public"."esignature_documents" IS E'Tracking the lifecycle of esignature documents. ';
CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"()
RETURNS TRIGGER AS $$
DECLARE
_new record;
BEGIN
_new := NEW;
_new."updated_at" = NOW();
RETURN _new;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "set_public_esignature_documents_updated_at"
BEFORE UPDATE ON "public"."esignature_documents"
FOR EACH ROW
EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"();
COMMENT ON TRIGGER "set_public_esignature_documents_updated_at" ON "public"."esignature_documents"
IS 'trigger to set value of column "updated_at" to current timestamp on row update';
CREATE EXTENSION IF NOT EXISTS pgcrypto;

View File

@@ -0,0 +1 @@
alter table "public"."esignature_documents" drop constraint "esignature_documents_documentid_fkey";

View File

@@ -0,0 +1,5 @@
alter table "public"."esignature_documents"
add constraint "esignature_documents_documentid_fkey"
foreign key ("documentid")
references "public"."documents"
("id") on update restrict on delete restrict;

View File

@@ -0,0 +1 @@
ALTER TABLE "public"."esignature_documents" ALTER COLUMN "recipients" TYPE ARRAY;

View File

@@ -0,0 +1 @@
ALTER TABLE "public"."esignature_documents" ALTER COLUMN "recipients" TYPE json[];

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"."esignature_documents" add column "completed_at" timestamptz
-- null;

View File

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

View File

@@ -0,0 +1,4 @@
comment on column "public"."esignature_documents"."viewed" is E'Tracking the lifecycle of esignature documents. ';
alter table "public"."esignature_documents" alter column "viewed" set default false;
alter table "public"."esignature_documents" alter column "viewed" drop not null;
alter table "public"."esignature_documents" add column "viewed" bool;

View File

@@ -0,0 +1 @@
alter table "public"."esignature_documents" drop column "viewed" cascade;

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

View File

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

749
package-lock.json generated
View File

@@ -19,6 +19,8 @@
"@aws-sdk/credential-provider-node": "^3.972.28",
"@aws-sdk/lib-storage": "^3.1020.0",
"@aws-sdk/s3-request-presigner": "^3.1020.0",
"@documenso/sdk-typescript": "^0.8.0",
"@jsreport/nodejs-client": "^4.1.0",
"@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0",
@@ -54,6 +56,7 @@
"mustache": "^4.2.0",
"node-persist": "^4.0.4",
"nodemailer": "^6.10.0",
"normalize-url": "^9.0.0",
"pdf-lib": "^1.17.1",
"phone": "^3.1.71",
"query-string": "7.1.3",
@@ -1360,6 +1363,18 @@
"kuler": "^2.0.0"
}
},
"node_modules/@documenso/sdk-typescript": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@documenso/sdk-typescript/-/sdk-typescript-0.8.0.tgz",
"integrity": "sha512-Emzd5j+v8tA8gxtL+M/svVuzSOKMZw3/U4bS8zRoagvQEqkt+XNU2JraPEAJzxTjf3ww6EnlURXydbglBmR7AQ==",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.26.0",
"zod": "^3.25.0 || ^4.0.0"
},
"bin": {
"mcp": "bin/mcp-server.js"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
@@ -2196,6 +2211,18 @@
"node": ">=6"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.9",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
},
"peerDependencies": {
"hono": "^4"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -2303,12 +2330,370 @@
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/@jsreport/nodejs-client": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@jsreport/nodejs-client/-/nodejs-client-4.1.0.tgz",
"integrity": "sha512-QWupUQzMzxWFvY+AlSdUZGlinJv4cKhYmVE9rIe+he7rn4B24tezFmNdnrDcTSFv3hj4x7sTNqpeHT0fItfs5Q==",
"dependencies": {
"axios": "1.13.2",
"concat-stream": "2.0.0",
"mimic-response": "2.1.0"
},
"engines": {
"node": ">=22.18"
}
},
"node_modules/@jsreport/nodejs-client/node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/@jsreport/nodejs-client/node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"hono": "^4.11.4",
"jose": "^6.1.3",
"json-schema-typed": "^8.0.2",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@cfworker/json-schema": "^4.1.1",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"@cfworker/json-schema": {
"optional": true
},
"zod": {
"optional": false
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/jose": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
@@ -4183,6 +4568,45 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv-formats/node_modules/ajv": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@@ -4641,6 +5065,100 @@
"node": "*"
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/body-parser/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/body-parser/node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -6370,6 +6888,27 @@
"node": ">=0.8.x"
}
},
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/eventsource-parser": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -6426,6 +6965,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express/node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -6548,6 +7105,22 @@
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-builder": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
@@ -7429,6 +8002,15 @@
"node": ">= 0.4"
}
},
"node_modules/hono": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz",
"integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/hpagent": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz",
@@ -7687,6 +8269,15 @@
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -7928,6 +8519,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -8201,6 +8798,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-typed": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -8700,6 +9303,18 @@
"node": ">= 0.6"
}
},
"node_modules/mimic-response": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz",
"integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@@ -8989,6 +9604,18 @@
"node": ">=0.10.0"
}
},
"node_modules/normalize-url": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.0.tgz",
"integrity": "sha512-z9nC87iaZXXySbWWtTHfCFJyFvKaUAW6lODhikG7ILSbVgmwuFjUqkgnheHvAUcGedO29e2QGBRXMUD64aurqQ==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/notepack.io": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-3.0.1.tgz",
@@ -9452,6 +10079,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"license": "MIT",
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -9687,6 +10323,66 @@
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/raw-body/node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body/node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -9852,6 +10548,15 @@
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
@@ -10044,6 +10749,32 @@
"fsevents": "~2.3.2"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/router/node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/rsa-pem-from-mod-exp": {
"version": "0.8.6",
"resolved": "https://registry.npmjs.org/rsa-pem-from-mod-exp/-/rsa-pem-from-mod-exp-0.8.6.tgz",
@@ -12310,6 +13041,24 @@
"engines": {
"node": ">= 14"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.1",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25 || ^4"
}
}
}
}

View File

@@ -28,6 +28,8 @@
"@aws-sdk/credential-provider-node": "^3.972.28",
"@aws-sdk/lib-storage": "^3.1020.0",
"@aws-sdk/s3-request-presigner": "^3.1020.0",
"@documenso/sdk-typescript": "^0.8.0",
"@jsreport/nodejs-client": "^4.1.0",
"@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0",
@@ -63,6 +65,7 @@
"mustache": "^4.2.0",
"node-persist": "^4.0.4",
"nodemailer": "^6.10.0",
"normalize-url": "^9.0.0",
"pdf-lib": "^1.17.1",
"phone": "^3.1.71",
"query-string": "7.1.3",

View File

@@ -130,6 +130,7 @@ const applyRoutes = ({ app }) => {
app.use("/ai", require("./server/routes/aiRoutes"));
app.use("/chatter", require("./server/routes/chatterRoutes"));
app.use("/esign", require("./server/routes/esignRoutes"));
// Default route for forbidden access
app.get("/", (req, res) => {

View File

@@ -98,12 +98,26 @@ exports.PbsSelectedCustomer = async function PbsSelectedCustomer(socket, selecte
socket.JobData.ownr_fn || ""
} ${socket.JobData.ownr_ln || ""} ${socket.JobData.ownr_co_nm || ""}`
);
const ownerRef = await UpsertContactData(socket, selectedCustomerId);
socket.ownerRef = ownerRef;
WsLogger.createLogEvent(socket, "INFO", `Upserting vehicle information to DMS for ${socket.JobData.v_vin}`);
const vehicleRef = await UpsertVehicleData(socket, ownerRef.ReferenceId);
socket.vehicleRef = vehicleRef;
//If this is an AR customer, don't do anything.
const selectedCustomer = [...(socket.DMSVehCustomer ? [{ ...socket.DMSVehCustomer, vinOwner: true }] : []),
...socket.DMSCustList]?.find((cust) => cust.ContactId === selectedCustomerId);
if (selectedCustomer?.IsARCustomer) {
WsLogger.createLogEvent(socket, "INFO", `Skipping contact and vehicle update becuase it is marked as an AR contact in PBS.`);
}
else {
const ownerRef = await UpsertContactData(socket, selectedCustomerId);
socket.ownerRef = ownerRef;
WsLogger.createLogEvent(socket, "INFO", `Upserting vehicle information to DMS for ${socket.JobData.v_vin}`);
const vehicleRef = await UpsertVehicleData(socket, ownerRef.ReferenceId);
socket.vehicleRef = vehicleRef;
}
} else {
WsLogger.createLogEvent(
socket,

View File

@@ -1,73 +1,71 @@
const { isString } = require("lodash");
const { sendServerEmail } = require("../email/sendemail");
const logger = require("../utils/logger");
const { raw } = require("express");
const SUPPORT_EMAIL = "patrick@imexsystems.ca";
const SUPPORT_EMAIL = "support@imexsystems.ca";
const safeJsonParse = (maybeJson) => {
if (!isString(maybeJson)) return null;
try {
return JSON.parse(maybeJson);
} catch {
return null;
}
if (!isString(maybeJson)) return null;
try {
return JSON.parse(maybeJson);
} catch {
return null;
}
};
const handleBillAiFeedback = async (req, res) => {
try {
const rating = req.body?.rating;
const comments = isString(req.body?.comments) ? req.body?.comments?.trim() : "";
try {
const rating = req.body?.rating;
const comments = isString(req.body?.comments) ? req.body?.comments?.trim() : "";
const billFormValues = safeJsonParse(req.body?.billFormValues);
const rawAIData = safeJsonParse(req.body?.rawAIData);
const billFormValues = safeJsonParse(req.body?.billFormValues);
const rawAIData = safeJsonParse(req.body?.rawAIData);
const jobid = billFormValues?.jobid || billFormValues?.jobId || "unknown";
const shopname = req.body?.shopname || "unknown";
const subject = `Bill AI Feedback (${rating === "up" ? "+" : "-"}) Shop=${shopname} jobid=${jobid}`;
const jobid = billFormValues?.jobid || billFormValues?.jobId || "unknown";
const shopname = req.body?.shopname || "unknown";
const subject = `Bill AI Feedback (${rating === "up" ? "+" : "-"}) Shop=${shopname} jobid=${jobid}`;
const text = [
`User: ${req?.user?.email || "unknown"}`,
`Rating: ${rating}`,
comments ? `Comments: ${comments}` : "Comments: (none)",
"",
"Form Values (User):",
JSON.stringify(billFormValues, null, 4),
"",
"Raw AI Data:",
JSON.stringify(rawAIData, null, 4)
]
.filter(Boolean)
.join("\n");
const text = [
`User: ${req?.user?.email || "unknown"}`,
`Rating: ${rating}`,
comments ? `Comments: ${comments}` : "Comments: (none)",
"",
"Form Values (User):",
JSON.stringify(billFormValues, null, 4),
"",
"Raw AI Data:",
JSON.stringify(rawAIData, null, 4)
]
.filter(Boolean)
.join("\n");
const attachments = [];
if (req.file?.buffer) {
attachments.push({
filename: req.file.originalname || `bill-${jobid}.pdf`,
content: req.file.buffer,
contentType: req.file.mimetype || "application/pdf"
});
}
await sendServerEmail({
to: [SUPPORT_EMAIL],
subject,
type: "text",
text,
attachments
});
return res.json({ success: true });
} catch (error) {
logger.log("bill-ai-feedback-error", "ERROR", req?.user?.email, null, {
message: error?.message,
stack: error?.stack
});
return res.status(500).json({ message: "Failed to submit feedback" });
const attachments = [];
if (req.file?.buffer) {
attachments.push({
filename: req.file.originalname || `bill-${jobid}.pdf`,
content: req.file.buffer,
contentType: req.file.mimetype || "application/pdf"
});
}
await sendServerEmail({
to: [SUPPORT_EMAIL],
subject,
type: "text",
text,
attachments
});
return res.json({ success: true });
} catch (error) {
logger.log("bill-ai-feedback-error", "ERROR", req?.user?.email, null, {
message: error?.message,
stack: error?.stack
});
return res.status(500).json({ message: "Failed to submit feedback" });
}
};
module.exports = {
handleBillAiFeedback
handleBillAiFeedback
};

View File

@@ -1,8 +1,20 @@
const { TextractClient, StartExpenseAnalysisCommand, GetExpenseAnalysisCommand, AnalyzeExpenseCommand } = require("@aws-sdk/client-textract");
const {
TextractClient,
StartExpenseAnalysisCommand,
GetExpenseAnalysisCommand,
AnalyzeExpenseCommand
} = require("@aws-sdk/client-textract");
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
const { SQSClient, ReceiveMessageCommand, DeleteMessageCommand } = require("@aws-sdk/client-sqs");
const { v4: uuidv4 } = require('uuid');
const { getTextractJobKey, setTextractJob, getTextractJob, getFileType, getPdfPageCount, hasActiveJobs } = require("./bill-ocr-helpers");
const { v4: uuidv4 } = require("uuid");
const {
getTextractJobKey,
setTextractJob,
getTextractJob,
getFileType,
getPdfPageCount,
hasActiveJobs
} = require("./bill-ocr-helpers");
const { extractInvoiceData, processScanData } = require("./bill-ocr-normalize");
const { generateBillFormData } = require("./bill-ocr-generator");
const logger = require("../../utils/logger");
@@ -10,11 +22,11 @@ const _ = require("lodash");
// Initialize AWS clients
const awsConfig = {
region: process.env.AWS_AI_REGION || "ca-central-1",
credentials: {
accessKeyId: process.env.AWS_AI_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_AI_SECRET_ACCESS_KEY,
}
region: process.env.AWS_AI_REGION || "ca-central-1",
credentials: {
accessKeyId: process.env.AWS_AI_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_AI_SECRET_ACCESS_KEY
}
};
const textractClient = new TextractClient(awsConfig);
@@ -23,318 +35,339 @@ const sqsClient = new SQSClient(awsConfig);
let redisPubClient = null;
/**
* Initialize the bill-ocr module with Redis client
* @param {Object} pubClient - Redis cluster client
*/
function initializeBillOcr(pubClient) {
redisPubClient = pubClient;
redisPubClient = pubClient;
}
/**
* Check if job exists by Textract job ID
* @param {string} textractJobId
* @param {string} textractJobId
* @returns {Promise<boolean>}
*/
async function jobExists(textractJobId) {
if (!redisPubClient) {
throw new Error('Redis client not initialized. Call initializeBillOcr first.');
}
const key = getTextractJobKey(textractJobId);
const exists = await redisPubClient.exists(key);
if (!redisPubClient) {
throw new Error("Redis client not initialized. Call initializeBillOcr first.");
}
const key = getTextractJobKey(textractJobId);
const exists = await redisPubClient.exists(key);
if (exists) {
return true;
}
return false;
if (exists) {
return true;
}
return false;
}
async function handleBillOcr(req, res) {
// Check if file was uploaded
if (!req.file) {
return res.status(400).send({ error: 'No file uploaded.' });
}
// Check if file was uploaded
if (!req.file) {
return res.status(400).send({ error: "No file uploaded." });
}
// The uploaded file is available in request file
const uploadedFile = req.file;
const { jobid, bodyshopid, partsorderid } = req.body;
logger.log("bill-ocr-start", "DEBUG", req.user.email, jobid, null);
// The uploaded file is available in request file
const uploadedFile = req.file;
const { jobid, bodyshopid, partsorderid } = req.body;
logger.log("bill-ocr-start", "DEBUG", req.user.email, jobid, null);
try {
const fileType = getFileType(uploadedFile);
// Images are always processed synchronously (single page)
if (fileType === 'image') {
const processedData = await processSinglePageDocument(uploadedFile.buffer);
const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: req });
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ..._.omit(processedData, "originalTextractResponse"), billForm });
try {
const fileType = getFileType(uploadedFile);
// Images are always processed synchronously (single page)
if (fileType === "image") {
const processedData = await processSinglePageDocument(uploadedFile.buffer);
const billForm = await generateBillFormData({
processedData: processedData,
jobid,
bodyshopid,
partsorderid,
req: req
});
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, {
..._.omit(processedData, "originalTextractResponse"),
billForm
});
return res.status(200).json({
success: true,
status: 'COMPLETED',
data: { ...processedData, billForm },
message: 'Invoice processing completed'
});
} else if (fileType === 'pdf') {
// Check the number of pages in the PDF
const pageCount = await getPdfPageCount(uploadedFile.buffer);
return res.status(200).json({
success: true,
status: "COMPLETED",
data: { ...processedData, billForm },
message: "Invoice processing completed"
});
} else if (fileType === "pdf") {
// Check the number of pages in the PDF
const pageCount = await getPdfPageCount(uploadedFile.buffer);
if (pageCount === 1) {
// Process synchronously for single-page documents
const processedData = await processSinglePageDocument(uploadedFile.buffer);
const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: req });
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ..._.omit(processedData, "originalTextractResponse"), billForm });
return res.status(200).json({
success: true,
status: 'COMPLETED',
data: { ...processedData, billForm },
message: 'Invoice processing completed'
});
}
// Start the Textract job (non-blocking) for multi-page documents
const jobInfo = await startTextractJob(uploadedFile.buffer, { jobid, bodyshopid, partsorderid });
logger.log("bill-ocr-multipage-start", "DEBUG", req.user.email, jobid, jobInfo);
return res.status(202).json({
success: true,
textractJobId: jobInfo.jobId,
message: 'Invoice processing started',
statusUrl: `/ai/bill-ocr/status/${jobInfo.jobId}`
});
} else {
logger.log("bill-ocr-unsupported-filetype", "WARN", req.user.email, jobid, { fileType });
return res.status(400).json({
error: 'Unsupported file type',
message: 'Please upload a PDF or supported image file (JPEG, PNG, TIFF)'
});
}
} catch (error) {
logger.log("bill-ocr-error", "ERROR", req.user.email, jobid, { error: error.message, stack: error.stack });
return res.status(500).json({
error: 'Failed to start invoice processing',
message: error.message
if (pageCount === 1) {
// Process synchronously for single-page documents
const processedData = await processSinglePageDocument(uploadedFile.buffer);
const billForm = await generateBillFormData({
processedData: processedData,
jobid,
bodyshopid,
partsorderid,
req: req
});
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, {
..._.omit(processedData, "originalTextractResponse"),
billForm
});
return res.status(200).json({
success: true,
status: "COMPLETED",
data: { ...processedData, billForm },
message: "Invoice processing completed"
});
}
// Start the Textract job (non-blocking) for multi-page documents
const jobInfo = await startTextractJob(uploadedFile.buffer, { jobid, bodyshopid, partsorderid });
logger.log("bill-ocr-multipage-start", "DEBUG", req.user.email, jobid, jobInfo);
return res.status(202).json({
success: true,
textractJobId: jobInfo.jobId,
message: "Invoice processing started",
statusUrl: `/ai/bill-ocr/status/${jobInfo.jobId}`
});
} else {
logger.log("bill-ocr-unsupported-filetype", "WARN", req.user.email, jobid, { fileType });
return res.status(400).json({
error: "Unsupported file type",
message: "Please upload a PDF or supported image file (JPEG, PNG, TIFF)"
});
}
} catch (error) {
logger.log("bill-ocr-error", "ERROR", req.user.email, jobid, { error: error.message, stack: error.stack });
return res.status(500).json({
error: "Failed to start invoice processing",
message: error.message
});
}
}
async function handleBillOcrStatus(req, res) {
const { textractJobId } = req.params;
const { textractJobId } = req.params;
if (!textractJobId) {
logger.log("bill-ocr-status-error", "WARN", req.user.email, null, { error: 'No textractJobId found in params' });
return res.status(400).json({ error: 'Job ID is required' });
if (!textractJobId) {
logger.log("bill-ocr-status-error", "WARN", req.user.email, null, { error: "No textractJobId found in params" });
return res.status(400).json({ error: "Job ID is required" });
}
const jobStatus = await getTextractJob({ redisPubClient, textractJobId });
}
const jobStatus = await getTextractJob({ redisPubClient, textractJobId });
if (!jobStatus) {
return res.status(404).json({ error: "Job not found" });
}
if (!jobStatus) {
return res.status(404).json({ error: 'Job not found' });
}
if (jobStatus.status === "COMPLETED") {
// Generate billForm on-demand if not already generated
let billForm = jobStatus.data?.billForm;
if (jobStatus.status === 'COMPLETED') {
// Generate billForm on-demand if not already generated
let billForm = jobStatus.data?.billForm;
if (!billForm && jobStatus.context) {
try {
billForm = await generateBillFormData({
processedData: jobStatus.data,
jobid: jobStatus.context.jobid,
bodyshopid: jobStatus.context.bodyshopid,
partsorderid: jobStatus.context.partsorderid,
req: req // Now we have request context!
});
logger.log("bill-ocr-multipage-complete", "DEBUG", req.user.email, jobStatus.context.jobid, {
...jobStatus.data,
billForm
});
if (!billForm && jobStatus.context) {
try {
billForm = await generateBillFormData({
processedData: jobStatus.data,
jobid: jobStatus.context.jobid,
bodyshopid: jobStatus.context.bodyshopid,
partsorderid: jobStatus.context.partsorderid,
req: req // Now we have request context!
});
logger.log("bill-ocr-multipage-complete", "DEBUG", req.user.email, jobStatus.context.jobid, { ...jobStatus.data, billForm });
// Cache the billForm back to Redis for future requests
await setTextractJob({
redisPubClient,
textractJobId,
jobData: {
...jobStatus,
data: {
...jobStatus.data,
billForm
}
}
});
} catch (error) {
logger.log("bill-ocr-multipage-error", "ERROR", req.user.email, jobStatus.context.jobid, { ...jobStatus.data, error: error.message, stack: error.stack });
return res.status(500).send({
status: 'COMPLETED',
error: 'Data processed but failed to generate bill form',
message: error.message,
data: jobStatus.data // Still return the raw processed data
});
}
}
return res.status(200).send({
status: 'COMPLETED',
// Cache the billForm back to Redis for future requests
await setTextractJob({
redisPubClient,
textractJobId,
jobData: {
...jobStatus,
data: {
...jobStatus.data,
billForm
...jobStatus.data,
billForm
}
}
});
} catch (error) {
logger.log("bill-ocr-multipage-error", "ERROR", req.user.email, jobStatus.context.jobid, {
...jobStatus.data,
error: error.message,
stack: error.stack
});
} else if (jobStatus.status === 'FAILED') {
logger.log("bill-ocr-multipage-failed", "ERROR", req.user.email, jobStatus.context.jobid, { ...jobStatus.data, error: jobStatus.error, });
return res.status(500).json({
status: 'FAILED',
error: jobStatus.error
});
} else {
return res.status(200).json({
status: jobStatus.status
return res.status(500).send({
status: "COMPLETED",
error: "Data processed but failed to generate bill form",
message: error.message,
data: jobStatus.data // Still return the raw processed data
});
}
}
return res.status(200).send({
status: "COMPLETED",
data: {
...jobStatus.data,
billForm
}
});
} else if (jobStatus.status === "FAILED") {
logger.log("bill-ocr-multipage-failed", "ERROR", req.user.email, jobStatus.context.jobid, {
...jobStatus.data,
error: jobStatus.error
});
return res.status(500).json({
status: "FAILED",
error: jobStatus.error
});
} else {
return res.status(200).json({
status: jobStatus.status
});
}
}
/**
* Process a single-page document synchronously using AnalyzeExpenseCommand
* @param {Buffer} pdfBuffer
* @param {Buffer} pdfBuffer
* @returns {Promise<Object>}
*/
async function processSinglePageDocument(pdfBuffer) {
const analyzeCommand = new AnalyzeExpenseCommand({
Document: {
Bytes: pdfBuffer
}
});
const analyzeCommand = new AnalyzeExpenseCommand({
Document: {
Bytes: pdfBuffer
}
});
const result = await textractClient.send(analyzeCommand);
const invoiceData = extractInvoiceData(result);
const processedData = processScanData(invoiceData);
const result = await textractClient.send(analyzeCommand);
const invoiceData = extractInvoiceData(result);
const processedData = processScanData(invoiceData);
return {
...processedData,
//Removed as this is a large object that provides minimal value to send to client.
// originalTextractResponse: result
};
return {
...processedData
//Removed as this is a large object that provides minimal value to send to client.
// originalTextractResponse: result
};
}
async function startTextractJob(pdfBuffer, context = {}) {
// Upload PDF to S3 temporarily for Textract async processing
const { bodyshopid, jobid } = context;
const s3Bucket = process.env.AWS_AI_BUCKET;
const snsTopicArn = process.env.AWS_TEXTRACT_SNS_TOPIC_ARN;
const snsRoleArn = process.env.AWS_TEXTRACT_SNS_ROLE_ARN;
// Upload PDF to S3 temporarily for Textract async processing
const { bodyshopid, jobid } = context;
const s3Bucket = process.env.AWS_AI_BUCKET;
const snsTopicArn = process.env.AWS_TEXTRACT_SNS_TOPIC_ARN;
const snsRoleArn = process.env.AWS_TEXTRACT_SNS_ROLE_ARN;
if (!s3Bucket) {
throw new Error('AWS_AI_BUCKET environment variable is required');
}
if (!snsTopicArn) {
throw new Error('AWS_TEXTRACT_SNS_TOPIC_ARN environment variable is required');
}
if (!snsRoleArn) {
throw new Error('AWS_TEXTRACT_SNS_ROLE_ARN environment variable is required');
}
if (!s3Bucket) {
throw new Error("AWS_AI_BUCKET environment variable is required");
}
if (!snsTopicArn) {
throw new Error("AWS_TEXTRACT_SNS_TOPIC_ARN environment variable is required");
}
if (!snsRoleArn) {
throw new Error("AWS_TEXTRACT_SNS_ROLE_ARN environment variable is required");
}
const uploadId = uuidv4();
const s3Key = `textract-temp/${bodyshopid}/${jobid}/${uploadId}.pdf`; //TODO Update Keys structure to something better.
const uploadId = uuidv4();
const s3Key = `textract-temp/${bodyshopid}/${jobid}/${uploadId}.pdf`; //TODO Update Keys structure to something better.
// Upload to S3
const uploadCommand = new PutObjectCommand({
// Upload to S3
const uploadCommand = new PutObjectCommand({
Bucket: s3Bucket,
Key: s3Key,
Body: pdfBuffer,
ContentType: "application/pdf" //Hard coded - we only support PDFs for multi-page
});
await s3Client.send(uploadCommand);
// Start async Textract expense analysis with SNS notification
const startCommand = new StartExpenseAnalysisCommand({
DocumentLocation: {
S3Object: {
Bucket: s3Bucket,
Key: s3Key,
Body: pdfBuffer,
ContentType: 'application/pdf' //Hard coded - we only support PDFs for multi-page
});
await s3Client.send(uploadCommand);
Name: s3Key
}
},
NotificationChannel: {
SNSTopicArn: snsTopicArn,
RoleArn: snsRoleArn
},
ClientRequestToken: uploadId
});
// Start async Textract expense analysis with SNS notification
const startCommand = new StartExpenseAnalysisCommand({
DocumentLocation: {
S3Object: {
Bucket: s3Bucket,
Name: s3Key
}
},
NotificationChannel: {
SNSTopicArn: snsTopicArn,
RoleArn: snsRoleArn
},
ClientRequestToken: uploadId
});
const startResult = await textractClient.send(startCommand);
const textractJobId = startResult.JobId;
const startResult = await textractClient.send(startCommand);
const textractJobId = startResult.JobId;
// Store job info in Redis using textractJobId as the key
await setTextractJob({
redisPubClient,
textractJobId,
jobData: {
status: "IN_PROGRESS",
s3Key: s3Key,
uploadId: uploadId,
startedAt: new Date().toISOString(),
context: context // Store the context for later use
}
});
// Store job info in Redis using textractJobId as the key
await setTextractJob(
{
redisPubClient,
textractJobId,
jobData: {
status: 'IN_PROGRESS',
s3Key: s3Key,
uploadId: uploadId,
startedAt: new Date().toISOString(),
context: context // Store the context for later use
}
}
);
return {
jobId: textractJobId
};
return {
jobId: textractJobId
};
}
// Process SQS messages from Textract completion notifications
async function processSQSMessages() {
const queueUrl = process.env.AWS_TEXTRACT_SQS_QUEUE_URL;
const queueUrl = process.env.AWS_TEXTRACT_SQS_QUEUE_URL;
if (!queueUrl) {
logger.log("bill-ocr-error", "ERROR", "api", null, { message: "AWS_TEXTRACT_SQS_QUEUE_URL not configured" });
return;
}
// Only poll if there are active mutli page jobs in progress
const hasActive = await hasActiveJobs({ redisPubClient });
if (!hasActive) {
return;
}
// Only poll if there are active mutli page jobs in progress
const hasActive = await hasActiveJobs({ redisPubClient });
if (!hasActive) {
return;
}
try {
const receiveCommand = new ReceiveMessageCommand({
QueueUrl: queueUrl,
MaxNumberOfMessages: 10,
WaitTimeSeconds: 20,
MessageAttributeNames: ["All"]
});
try {
const receiveCommand = new ReceiveMessageCommand({
QueueUrl: queueUrl,
MaxNumberOfMessages: 10,
WaitTimeSeconds: 20,
MessageAttributeNames: ['All']
});
const result = await sqsClient.send(receiveCommand);
const result = await sqsClient.send(receiveCommand);
if (result.Messages && result.Messages.length > 0) {
logger.log("bill-ocr-sqs-processing", "DEBUG", "api", null, {
message: `Processing ${result.Messages.length} messages from SQS`
});
for (const message of result.Messages) {
try {
// Environment-level filtering: check if this message belongs to this environment
const shouldProcess = await shouldProcessMessage(message);
if (result.Messages && result.Messages.length > 0) {
logger.log("bill-ocr-sqs-processing", "DEBUG", "api", null, { message: `Processing ${result.Messages.length} messages from SQS` });
for (const message of result.Messages) {
try {
// Environment-level filtering: check if this message belongs to this environment
const shouldProcess = await shouldProcessMessage(message);
if (shouldProcess) {
await handleTextractNotification(message);
// Delete message after successful processing
const deleteCommand = new DeleteMessageCommand({
QueueUrl: queueUrl,
ReceiptHandle: message.ReceiptHandle
});
await sqsClient.send(deleteCommand);
}
} catch (error) {
logger.log("bill-ocr-sqs-processing-error", "ERROR", "api", null, { message, error: error.message, stack: error.stack });
}
}
if (shouldProcess) {
await handleTextractNotification(message);
// Delete message after successful processing
const deleteCommand = new DeleteMessageCommand({
QueueUrl: queueUrl,
ReceiptHandle: message.ReceiptHandle
});
await sqsClient.send(deleteCommand);
}
} catch (error) {
logger.log("bill-ocr-sqs-processing-error", "ERROR", "api", null, {
message,
error: error.message,
stack: error.stack
});
}
} catch (error) {
logger.log("bill-ocr-sqs-receiving-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
}
}
} catch (error) {
logger.log("bill-ocr-sqs-receiving-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
}
}
/**
@@ -343,125 +376,140 @@ async function processSQSMessages() {
* @returns {Promise<boolean>}
*/
async function shouldProcessMessage(message) {
try {
const body = JSON.parse(message.Body);
const snsMessage = JSON.parse(body.Message);
const textractJobId = snsMessage.JobId;
try {
const body = JSON.parse(message.Body);
const snsMessage = JSON.parse(body.Message);
const textractJobId = snsMessage.JobId;
// Check if job exists in Redis for this environment (using environment-specific prefix)
const exists = await jobExists(textractJobId);
return exists;
} catch (error) {
logger.log("bill-ocr-message-check-error", "DEBUG", "api", null, { message: "Error checking if message should be processed", error: error.message, stack: error.stack });
// If we can't parse the message, don't process it
return false;
}
// Check if job exists in Redis for this environment (using environment-specific prefix)
const exists = await jobExists(textractJobId);
return exists;
} catch (error) {
logger.log("bill-ocr-message-check-error", "DEBUG", "api", null, {
message: "Error checking if message should be processed",
error: error.message,
stack: error.stack
});
// If we can't parse the message, don't process it
return false;
}
}
async function handleTextractNotification(message) {
const body = JSON.parse(message.Body);
let snsMessage
try {
snsMessage = JSON.parse(body.Message);
} catch (error) {
logger.log("bill-ocr-handle-textract-error", "DEBUG", "api", null, { message: "Error parsing SNS message - invalid message format.", error: error.message, stack: error.stack, body });
return;
}
const body = JSON.parse(message.Body);
let snsMessage;
try {
snsMessage = JSON.parse(body.Message);
} catch (error) {
logger.log("bill-ocr-handle-textract-error", "DEBUG", "api", null, {
message: "Error parsing SNS message - invalid message format.",
error: error.message,
stack: error.stack,
body
});
return;
}
const textractJobId = snsMessage.JobId;
const status = snsMessage.Status;
const textractJobId = snsMessage.JobId;
const status = snsMessage.Status;
// Get job info from Redis
const jobInfo = await getTextractJob({ redisPubClient, textractJobId });
// Get job info from Redis
const jobInfo = await getTextractJob({ redisPubClient, textractJobId });
if (!jobInfo) {
logger.log("bill-ocr-job-not-found", "DEBUG", "api", null, { message: `Job info not found in Redis for Textract job ID: ${textractJobId}`, textractJobId, snsMessage });
return;
}
if (!jobInfo) {
logger.log("bill-ocr-job-not-found", "DEBUG", "api", null, {
message: `Job info not found in Redis for Textract job ID: ${textractJobId}`,
textractJobId,
snsMessage
});
return;
}
if (status === 'SUCCEEDED') {
// Retrieve the results
const { processedData, originalResponse } = await retrieveTextractResults(textractJobId);
if (status === "SUCCEEDED") {
// Retrieve the results
const { processedData, originalResponse } = await retrieveTextractResults(textractJobId);
// Store the processed data - billForm will be generated on-demand in the status endpoint
await setTextractJob(
{
redisPubClient,
textractJobId,
jobData: {
...jobInfo,
status: 'COMPLETED',
data: {
...processedData,
//Removed as this is a large object that provides minimal value to send to client.
// originalTextractResponse: originalResponse
},
completedAt: new Date().toISOString()
}
}
);
} else if (status === 'FAILED') {
await setTextractJob(
{
redisPubClient,
textractJobId,
jobData: {
...jobInfo,
status: 'FAILED',
error: snsMessage.StatusMessage || 'Textract job failed',
completedAt: new Date().toISOString()
}
}
);
}
// Store the processed data - billForm will be generated on-demand in the status endpoint
await setTextractJob({
redisPubClient,
textractJobId,
jobData: {
...jobInfo,
status: "COMPLETED",
data: {
...processedData
//Removed as this is a large object that provides minimal value to send to client.
// originalTextractResponse: originalResponse
},
completedAt: new Date().toISOString()
}
});
} else if (status === "FAILED") {
await setTextractJob({
redisPubClient,
textractJobId,
jobData: {
...jobInfo,
status: "FAILED",
error: snsMessage.StatusMessage || "Textract job failed",
completedAt: new Date().toISOString()
}
});
}
}
async function retrieveTextractResults(textractJobId) {
// Handle pagination if there are multiple pages of results
let allExpenseDocuments = [];
let nextToken = null;
// Handle pagination if there are multiple pages of results
let allExpenseDocuments = [];
let nextToken = null;
do {
const getCommand = new GetExpenseAnalysisCommand({
JobId: textractJobId,
NextToken: nextToken
});
do {
const getCommand = new GetExpenseAnalysisCommand({
JobId: textractJobId,
NextToken: nextToken
});
const result = await textractClient.send(getCommand);
const result = await textractClient.send(getCommand);
if (result.ExpenseDocuments) {
allExpenseDocuments = allExpenseDocuments.concat(result.ExpenseDocuments);
}
if (result.ExpenseDocuments) {
allExpenseDocuments = allExpenseDocuments.concat(result.ExpenseDocuments);
}
nextToken = result.NextToken;
} while (nextToken);
nextToken = result.NextToken;
} while (nextToken);
// Store the complete original response
const fullTextractResponse = { ExpenseDocuments: allExpenseDocuments };
// Store the complete original response
const fullTextractResponse = { ExpenseDocuments: allExpenseDocuments };
// Extract invoice data from Textract response
const invoiceData = extractInvoiceData(fullTextractResponse);
// Extract invoice data from Textract response
const invoiceData = extractInvoiceData(fullTextractResponse);
return {
processedData: processScanData(invoiceData),
originalResponse: fullTextractResponse
};
return {
processedData: processScanData(invoiceData),
originalResponse: fullTextractResponse
};
}
// Start SQS polling (call this when server starts)
function startSQSPolling() {
const pollInterval = setInterval(() => {
processSQSMessages().catch(error => {
logger.log("bill-ocr-sqs-poll-error", "ERROR", "api", null, { message: error.message, stack: error.stack });
});
}, 10000); // Poll every 10 seconds
return pollInterval;
const queueUrl = process.env.AWS_TEXTRACT_SQS_QUEUE_URL;
if (!queueUrl) {
logger.log("bill-ocr-error", "ERROR", "api", null, { message: "AWS_TEXTRACT_SQS_QUEUE_URL not configured" });
return;
}
const pollInterval = setInterval(() => {
processSQSMessages().catch((error) => {
logger.log("bill-ocr-sqs-poll-error", "ERROR", "api", null, { message: error.message, stack: error.stack });
});
}, 10000); // Poll every 10 seconds
return pollInterval;
}
module.exports = {
initializeBillOcr,
handleBillOcr,
handleBillOcrStatus,
startSQSPolling
};
initializeBillOcr,
handleBillOcr,
handleBillOcrStatus,
startSQSPolling
};

View File

@@ -52,7 +52,6 @@ exports.default = async (req, res) => {
emailer
.sendTaskEmail({
to: [
"patrick.fic@convenient-brands.com",
"bradley.rhoades@convenient-brands.com",
"jrome@rometech.com",
"ivana@imexsystems.ca",

View File

@@ -422,7 +422,6 @@ const emailBounce = async (req, res) => {
rome: `Rome Online <noreply@romeonline.io>`
}),
to: replyTo,
//bcc: "patrick@snapt.ca",
subject: `${InstanceManager({
imex: "ImEX Online",
rome: "Rome Online"

488
server/esign/esign-new.js Normal file
View File

@@ -0,0 +1,488 @@
const { Documenso } = require("@documenso/sdk-typescript");
const axios = require("axios");
const { jsrAuthString } = require("../utils/utils");
const logger = require("../utils/logger");
//Need to pull the key dynamically to send documents.
const JSR_SERVER = process.env.JSR_URL || "https://reports.imex.online";
const jsreport = require("@jsreport/nodejs-client");
const { QUERY_JOB_FOR_SIGNATURE, INSERT_ESIGNATURE_DOCUMENT, DISTRIBUTE_ESIGNATURE_DOCUMENT, QUERY_ESIGNATURE_BY_EXTERNAL_ID, UPDATE_ESIGNATURE_DOCUMENT, QUERY_DOCUMENSO_KEY, INSERT_ESIG_AUDIT_TRAIL } = require("../graphql-client/queries");
const _ = require("lodash");
function parseJsonField(value, fallback = null) {
if (value === undefined || value === null) {
return fallback;
}
if (typeof value !== "string") {
return value;
}
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function getDefaultEsignData({ esigData, bodyshop, fileName }) {
const fallbackTitle = fileName || `Esign request from ${bodyshop.shopname}`;
return {
...esigData,
title: esigData?.title || fallbackTitle,
subject: esigData?.subject || `Esign request from ${bodyshop.shopname}`,
message: esigData?.message || `Please review and sign the document from ${bodyshop.shopname}.`
};
}
function createClientError(message, statusCode = 400) {
const error = new Error(message);
error.statusCode = statusCode;
return error;
}
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function getJobOwnerName(jobData, email) {
const ownerName = [jobData?.ownr_fn, jobData?.ownr_ln].filter(Boolean).join(" ").trim();
return ownerName || jobData?.ownr_co_nm || email;
}
function getJobOwnerRecipients(jobData) {
const ownerEmail = jobData?.ownr_ea?.trim();
if (!ownerEmail) {
throw createClientError("Job owner email is required before sending an e-signature request.");
}
if (!isValidEmail(ownerEmail)) {
throw createClientError(`Job owner email "${ownerEmail}" is not valid.`);
}
return [
{
email: ownerEmail,
name: getJobOwnerName(jobData, ownerEmail),
role: "SIGNER"
}
];
}
async function getDocumensoClient({ bodyshopid, req }) {
const client = req.userGraphQLClient;
const { bodyshops_by_pk: { documenso_api_key } } = await client.request(QUERY_DOCUMENSO_KEY, { bodyshopid });
return new Documenso({
apiKey: documenso_api_key,//Done on a by team basis,
serverURL: "https://sign.imex.online/api/v2",
});
}
async function createEsignDocumentFromPdf({ req, bodyshop, pdfBuffer, esigData, fileName }) {
const resolvedEsigData = getDefaultEsignData({ esigData, bodyshop, fileName });
const fileBlob = new Blob([pdfBuffer], { type: "application/pdf" });
const jobid = req.body.jobid;
const client = req.userGraphQLClient;
const { jobs_by_pk: jobData } = await client.request(QUERY_JOB_FOR_SIGNATURE, { jobid });
const recipients = getJobOwnerRecipients(jobData);
const documenso = await getDocumensoClient({ bodyshopid: bodyshop.id, req })
const createDocumentResponse = await documenso.documents.create({
payload: {
title: resolvedEsigData.title,
externalId: `${jobid}|${req.user?.email}`,
recipients,
meta: {
timezone: bodyshop.timezone,
dateFormat: "MM/dd/yyyy hh:mm a",
language: "en",
subject: resolvedEsigData.subject,
message: resolvedEsigData.message,
}
},
file: fileBlob
});
const documentResult = await documenso.documents.get({
documentId: createDocumentResponse.id,
});
if (resolvedEsigData?.fields && resolvedEsigData.fields.length > 0) {
try {
await documenso.envelopes.fields.createMany({
envelopeId: createDocumentResponse.envelopeId,
data: resolvedEsigData.fields.map(sigField => ({ ...sigField, recipientId: documentResult.recipients[0].id, }))
});
} catch (error) {
logger.log(`esig-new-fields-error`, "ERROR", "esig", "api", {
message: error.message, stack: error.stack,
body: req.body
});
}
}
const presignToken = await documenso.embedding.embeddingPresignCreateEmbeddingPresignToken({});
await client.request(INSERT_ESIGNATURE_DOCUMENT, {
audit: {
jobid,
bodyshopid: bodyshop.id,
operation: `Esignature document created. Subject: ${resolvedEsigData.subject || "No subject"}, Message: ${resolvedEsigData.message || "No message"}. Document ID: ${createDocumentResponse.id} Envlope ID: ${createDocumentResponse.envelopeId}`,
useremail: req.user?.email,
type: 'esig-create'
},
esig: {
jobid,
external_document_id: createDocumentResponse.id.toString(),
subject: resolvedEsigData.subject || "No subject",
message: resolvedEsigData.message || "No message",
title: resolvedEsigData.title || "No title",
status: "DRAFT",
recipients: recipients,
}
});
return {
token: presignToken.token,
documentId: createDocumentResponse.id,
envelopeId: createDocumentResponse.envelopeId
};
}
async function distributeDocument(req, res) {
try {
const client = req.userGraphQLClient;
const { documentId, bodyshopid } = req.body;
const documenso = await getDocumensoClient({ bodyshopid, req })
const distributeResult = await documenso.documents.distribute({
documentId,
});
await client.request(DISTRIBUTE_ESIGNATURE_DOCUMENT, {
external_document_id: documentId.toString(),
esig_update: {
status: "SENT"
},
audit: {
jobid: req.body.jobid,
bodyshopid: req.body.bodyshopid,
operation: `Esignature document with title ${distributeResult.title} (ID: ${documentId}) distributed to recipients.`,
useremail: req.user?.email,
type: 'esig-distribute'
}
})
res.json({ success: true, distributeResult });
} catch (error) {
logger.log(`esig-distribute-error`, "ERROR", "esig", "api", {
message: error.message, stack: error.stack,
body: req.body
});
res.status(500).json({ error: "An error occurred while distributing the document.", message: error.message });
}
}
async function redistributeDocument(req, res) {
try {
const client = req.userGraphQLClient;
const { documentId, bodyshopid } = req.body;
const documenso = await getDocumensoClient({ bodyshopid, req })
const document = await documenso.documents.get({
documentId: parseInt(documentId)
});
const distributeResult = await documenso.documents.redistribute({
documentId: parseInt(documentId),
recipients: document.recipients.filter(r => r.signingStatus === "NOT_SIGNED").map(r => r.id)
});
await client.request(INSERT_ESIG_AUDIT_TRAIL, {
obj: {
jobid: req.body.jobid,
bodyshopid: req.body.bodyshopid,
operation: `Esignature document with title ${distributeResult.title} (ID: ${documentId}) redistributed to recipients.`,
useremail: req.user?.email,
type: 'esig-redistribute'
}
})
res.json({ success: true, distributeResult });
} catch (error) {
logger.log(`esig-redistribute-error`, "ERROR", "esig", "api", {
message: error.message, stack: error.stack,
body: req.body
});
res.status(500).json({ error: "An error occurred while redistributing the document.", message: error.message });
}
}
async function deleteDocument(req, res) {
try {
const client = req.userGraphQLClient;
const { documentId, bodyshopid } = req.body;
const { esignature_documents } = await client.request(QUERY_ESIGNATURE_BY_EXTERNAL_ID, { external_document_id: documentId.toString() });
if (!esignature_documents || esignature_documents.length === 0) {
//return res.status(404).json({ error: "Document not found" });
}
const documenso = await getDocumensoClient({ bodyshopid, req })
const deleteResult = await documenso.documents.delete({
documentId: (documentId)
});
await client.request(UPDATE_ESIGNATURE_DOCUMENT, {
external_document_id: documentId.toString(),
esig_update: {
status: "DELETED"
}
})
res.json({ success: true, deleteResult });
} catch (error) {
logger.log(`esig-delete-error`, "ERROR", "esig", "api", {
message: error.message, stack: error.stack,
body: req.body
});
res.status(500).json({ error: "An error occurred while deleting the document." });
}
}
async function viewDocument(req, res) {
try {
const { documentId, bodyshopid } = req.body;
const documenso = await getDocumensoClient({ bodyshopid, req })
const document = await documenso.document.documentDownload({
documentId: parseInt(documentId)
});
res.json({ success: true, document });
} catch (error) {
logger.log(`esig-view-error`, "ERROR", "esig", "api", {
message: error.message, stack: error.stack,
body: req.body
});
res.status(500).json({ error: "An error occurred while retrieving the document.", message: error.message });
}
}
async function newEsignDocument(req, res) {
try {
const client = req.userGraphQLClient;
const { bodyshop } = req.body;
const { pdf: fileBuffer, esigData } = await RenderTemplate({ client, req });
const result = await createEsignDocumentFromPdf({
req,
bodyshop,
pdfBuffer: fileBuffer,
esigData
});
res.json(result);
}
catch (error) {
logger.log(`esig-new-error`, "ERROR", "esig", "api", {
message: error.message, stack: error.stack,
body: _.omit(req.body, ["bodyshop"]) // bodyshop can be large, so we omit it from the logs
});
res.status(error.statusCode || 500).json({ error: "An error occurred while creating the e-sign document.", message: error.message });
}
}
async function newCustomEsignDocument(req, res) {
try {
const bodyshop = parseJsonField(req.body.bodyshop, req.body.bodyshop);
const esigData = parseJsonField(req.body.esigData, {});
const uploadedDocument = req.file;
if (!uploadedDocument?.buffer) {
return res.status(400).json({ error: "A PDF document is required." });
}
if (uploadedDocument.mimetype !== "application/pdf") {
return res.status(400).json({ error: "Only PDF documents can be used for e-signature." });
}
req.body.bodyshop = bodyshop;
const fileName = uploadedDocument.originalname?.replace(/\.[^.]+$/, "") || undefined;
const result = await createEsignDocumentFromPdf({
req,
bodyshop,
pdfBuffer: uploadedDocument.buffer,
esigData,
fileName
});
res.json(result);
}
catch (error) {
logger.log(`esig-new-custom-error`, "ERROR", "esig", "api", {
message: error.message, stack: error.stack,
body: _.omit(req.body, ["bodyshop"]) // bodyshop can be large, so we omit it from the logs
});
res.status(error.statusCode || 500).json({ error: "An error occurred while creating the custom e-sign document.", message: error.message });
}
}
async function RenderTemplate({ req }) {
const jsrAuth = jsrAuthString()
const jsreportClient = new jsreport(JSR_SERVER, process.env.JSR_USER, process.env.JSR_PASSWORD);
const { templateObject, bodyshop } = req.body;
let { contextData, useShopSpecificTemplate, shopSpecificFolder, esigData } = await fetchContextData({ templateObject, jsrAuth, req });
const { ignoreCustomMargins } = { ignoreCustomMargins: false }// Templates[templateObject.name];
let reportRequest = {
template: {
name: useShopSpecificTemplate ? `/${bodyshop.imexshopid}/${templateObject.name}` : `/${templateObject.name}`,
recipe: "chrome-pdf",
...(!ignoreCustomMargins && {
chrome: {
marginTop:
bodyshop.logo_img_path &&
bodyshop.logo_img_path.headerMargin &&
bodyshop.logo_img_path.headerMargin > 36
? bodyshop.logo_img_path.headerMargin
: "36px",
marginBottom:
bodyshop.logo_img_path &&
bodyshop.logo_img_path.footerMargin &&
bodyshop.logo_img_path.footerMargin > 50
? bodyshop.logo_img_path.footerMargin
: "50px"
}
}),
},
data: {
...contextData,
...templateObject.variables,
...templateObject.context,
headerpath: shopSpecificFolder ? `/${bodyshop.imexshopid}/header.html` : `/GENERIC/header.html`,
footerpath: shopSpecificFolder ? `/${bodyshop.imexshopid}/footer.html` : `/GENERIC/footer.html`,
bodyshop: bodyshop,
esignature: true,
filters: templateObject?.filters,
sorters: templateObject?.sorters,
offset: bodyshop.timezone, //dayjs().utcOffset(),
defaultSorters: templateObject?.defaultSorters
}
};
const render = await jsreportClient.render(reportRequest);
//Check render object and download. It should be the PDF?
const pdfBuffer = await render.body()
return { pdf: pdfBuffer, esigData }
}
const fetchContextData = async ({ templateObject, jsrAuth, req, }) => {
const { bodyshop } = req.body
const folders = await axios.get(`${JSR_SERVER}/odata/folders`, {
headers: { Authorization: jsrAuth }
});
const shopSpecificFolder = folders.data.value.find((f) => f.name === bodyshop.imexshopid);
const jsReportQueries = await axios.get(
`${JSR_SERVER}/odata/assets?$filter=name eq '${templateObject.name}.query'`,
{ headers: { Authorization: jsrAuth } }
);
const jsReportEsig = await axios.get(
`${JSR_SERVER}/odata/assets?$filter=name eq '${templateObject.name}.esig'`,
{ headers: { Authorization: jsrAuth } }
);
let templateQueryToExecute;
let esigData;
let useShopSpecificTemplate = false;
// let shopSpecificTemplate;
if (shopSpecificFolder) {
let shopSpecificTemplate = jsReportQueries.data.value.find(
(f) => f?.folder?.shortid === shopSpecificFolder.shortid
);
if (shopSpecificTemplate) {
useShopSpecificTemplate = true;
templateQueryToExecute = atob(shopSpecificTemplate.content);
}
let shopSpecificEsig = jsReportEsig.data.value.find(
(f) => f?.folder?.shortid === shopSpecificFolder.shortid
);
if (shopSpecificEsig) {
esigData = (atob(shopSpecificEsig.content));
}
}
if (!templateQueryToExecute) {
const generalTemplate = jsReportQueries.data.value.find((f) => !f.folder);
useShopSpecificTemplate = false;
templateQueryToExecute = atob(generalTemplate.content);
}
if (!esigData) {
const generalTemplate = jsReportEsig.data.value.find((f) => !f.folder);
useShopSpecificTemplate = false;
if (generalTemplate && generalTemplate.content) {
esigData = atob(generalTemplate?.content);
}
}
const client = req.userGraphQLClient;
// In the print center, we will never have sorters or filters.
// We have no template filters or sorters, so we can just execute the query and return the data
// if (!hasFilters && !hasSorters && !hasDefaultSorters) {
let contextData = {};
if (templateQueryToExecute) {
const data = await client.request(
templateQueryToExecute,
templateObject.variables,
);
contextData = data;
}
let parsedEsigData
try {
parsedEsigData = esigData ? JSON.parse(esigData) : null;
} catch (error) {
logger.log(`esig-data-parse-error`, "ERROR", "esig", "api", {
message: error.message, stack: error.stack,
esigData,
body: req.body
});
parsedEsigData = {}
}
return {
contextData,
useShopSpecificTemplate,
shopSpecificFolder,
esigData: parsedEsigData
};
// }
// return await generateTemplate(templateQueryToExecute, templateObject, useShopSpecificTemplate, shopSpecificFolder);
};
module.exports = {
newEsignDocument,
newCustomEsignDocument,
distributeDocument,
redistributeDocument,
deleteDocument,
viewDocument,
getDocumensoClient
}

337
server/esign/webhook.js Normal file
View File

@@ -0,0 +1,337 @@
const { Documenso } = require("@documenso/sdk-typescript");
const logger = require("../utils/logger");
const {
QUERY_META_FOR_ESIG_COMPLETION,
INSERT_ESIGNATURE_COMPLETED_DOCOUMENT,
UPDATE_ESIGNATURE_DOCUMENT,
DISTRIBUTE_ESIGNATURE_DOCUMENT,
GET_DOCUMENSO_KEY_BY_JOBID
} = require("../graphql-client/queries");
const replaceAccents = require("../utils/replaceAccents");
const { uploadFileBuffer } = require("../media/imgproxy-media");
const {
dispatchEsignDocumentOpenedNotification,
dispatchEsignDocumentCompletedNotification,
dispatchEsignDocumentUploadFailedNotification
} = require("../notifications/esignNotifications");
const axios = require("axios");
const normalizeUrl = require("normalize-url");
const client = require("../graphql-client/graphql-client").client;
/**
* Enumeration of webhook event types received from the e-signature service. These events represent different stages in
* the document signing process, such as when a document is created, sent, opened, signed, completed, rejected,
* cancelled, or when a reminder is sent. This enumeration is used to handle incoming webhook events and trigger
* appropriate actions based on the event type.
* @type {{DOCUMENT_CREATED: string, DOCUMENT_SENT: string, DOCUMENT_COMPLETED: string, DOCUMENT_REJECTED: string, DOCUMENT_CANCELLED: string, DOCUMENT_OPENED: string, DOCUMENT_SIGNED: string, DOCUMENT_REMINDER_SENT: string}}
*/
const webhookTypeEnums = {
DOCUMENT_CREATED: "DOCUMENT_CREATED",
DOCUMENT_SENT: "DOCUMENT_SENT",
DOCUMENT_COMPLETED: "DOCUMENT_COMPLETED",
DOCUMENT_REJECTED: "DOCUMENT_REJECTED",
DOCUMENT_CANCELLED: "DOCUMENT_CANCELLED",
DOCUMENT_OPENED: "DOCUMENT_OPENED",
DOCUMENT_SIGNED: "DOCUMENT_SIGNED",
DOCUMENT_REMINDER_SENT: "DOCUMENT_REMINDER_SENT"
};
/**
* Safely dispatches e-sign notifications by catching and logging any errors that occur during the dispatch process.
* This ensures that failures in notification dispatch do not affect the main flow of processing webhook events.
* The function takes an object containing the promise returned by the notification dispatch function, the event name,
* job ID, and document ID for logging purposes.
* @param param0
* @param param0.promise
* @param param0.eventName
* @param param0.jobid
* @param param0.documentId
*/
function dispatchEsignNotificationSafely({ promise, eventName, jobid, documentId }) {
promise.catch((error) => {
logger.log("esig-notification-dispatch-error", "ERROR", "notifications", "api", {
eventName,
jobid,
documentId,
message: error.message,
stack: error.stack
});
});
}
/**
* Handles incoming webhook events from the e-signature service. It processes different event types such as document
* opened, completed, rejected, etc., updates the document status in the database accordingly, and dispatches
* notifications to users. The function also includes error handling to log any issues that occur during processing and
* ensure a proper response is sent back to the webhook sender.
* @param req
* @param res
* @returns {Promise<void>}
*/
async function esignWebhook(req, res) {
try {
const message = req.body;
const documentPayload = message.payload || message.payload?.payload || {};
const externalId = documentPayload.externalId || documentPayload.payload?.externalId || "";
const [jobid, uploadedBy] = externalId.split("|");
logger.log(`esig-webhook-received`, "DEBUG", "redis", "api", {
event: message.event,
body: message
});
const documentId = (message.payload?.id || message.payload?.payload?.id)?.toString();
//TODO: Implement checks to prevent this from going backwards in status? If a request fails, it retries, which could cause a document marked as completed to be marked as rejected if the rejection event is processed after the completion event.
switch (message.event) {
case webhookTypeEnums.DOCUMENT_REMINDER_SENT:
break;
case webhookTypeEnums.DOCUMENT_OPENED:
await client.request(UPDATE_ESIGNATURE_DOCUMENT, {
external_document_id: documentId,
esig_update: {
status: "OPENED",
opened: true
}
});
dispatchEsignNotificationSafely({
promise: dispatchEsignDocumentOpenedNotification({
jobId: jobid,
documentId,
title: documentPayload.title,
uploadedBy,
logger
}),
eventName: webhookTypeEnums.DOCUMENT_OPENED,
jobid,
documentId
});
break;
case webhookTypeEnums.DOCUMENT_REJECTED:
await client.request(UPDATE_ESIGNATURE_DOCUMENT, {
external_document_id: documentId,
esig_update: {
status: "REJECTED",
rejected: true
}
});
break;
case webhookTypeEnums.DOCUMENT_CREATED:
//This is largely a throwaway event we know it was created.
break;
case webhookTypeEnums.DOCUMENT_COMPLETED:
await handleDocumentCompleted(message.payload);
break;
case webhookTypeEnums.DOCUMENT_SIGNED:
await client.request(UPDATE_ESIGNATURE_DOCUMENT, {
external_document_id: documentId,
esig_update: {
status: "SIGNED"
}
});
break;
default:
res.status(200).json({ message: "Unsupported event type." });
logger.log(`esig-webhook-received-unknown`, "ERROR", "redis", "api", {
event: message.event,
body: message
});
return;
}
logger.log(`esig-webhook-processed`, "INFO", "redis", "api", {
event: message.event,
documentId: message.payload?.payload?.id,
jobid: message.payload?.payload?.externalId?.split("|")[0] || null
});
res.sendStatus(200);
} catch (error) {
logger.log(`esig-webhook-error`, "ERROR", "redis", "api", {
message: error.message,
stack: error.stack,
body: req.body
});
res.status(500).json({ message: "Error processing webhook event.", error: error.message });
}
}
/**
* Handles the processing of a document completion event. This includes downloading the completed document from the
* e-signature service, uploading it to either a local media server or S3 depending on the bodyshop configuration,
* updating the document record in the database, and dispatching a notification about the document completion.
* The function also includes error handling to log any issues that occur during processing.
* @param payload
* @returns {Promise<void>}
*/
async function handleDocumentCompleted(payload) {
try {
//Split the external id to get the uploaded user.
const [jobid, uploaded_by] = payload.externalId.split("|");
if (!jobid || !uploaded_by) {
throw new Error(`Invalid externalId format. Expected "jobid|uploaded_by", got "${payload.externalId}"`);
}
const { jobs_by_pk } = await client.request(QUERY_META_FOR_ESIG_COMPLETION, {
jobid
});
//Have to use globally authed cleint since this a webhook.
const {
jobs_by_pk: {
bodyshop: { documenso_api_key }
}
} = await client.request(GET_DOCUMENSO_KEY_BY_JOBID, {
jobid
});
const documenso = new Documenso({
apiKey: documenso_api_key,
serverURL: "https://sign.imex.online/api/v2"
});
const document = await documenso.document.documentDownload({
documentId: payload.id
});
const response = await fetch(document.downloadUrl);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
let key = `${jobs_by_pk.bodyshop.id}/${jobs_by_pk.id}/${replaceAccents(document.filename).replace(/[^A-Z0-9]+/gi, "_")}-${new Date().getTime()}.pdf`;
const notifyUploadFailure = () =>
dispatchEsignNotificationSafely({
promise: dispatchEsignDocumentUploadFailedNotification({
jobId: jobs_by_pk.id,
documentId: payload.id.toString(),
title: payload.title,
uploadedBy: uploaded_by,
logger
}),
eventName: "DOCUMENT_UPLOAD_FAILED",
jobid,
documentId: payload.id.toString()
});
if (jobs_by_pk?.bodyshop?.uselocalmediaserver) {
const {
bodyshop: { localmediaserverhttp, localmediatoken }
} = jobs_by_pk;
const options = {
headers: {
"X-Requested-With": "XMLHttpRequest",
ims_token: localmediatoken
}
};
const formData = new FormData();
const fileName = document.filename?.toLowerCase().endsWith(".pdf")
? document.filename
: `${document.filename || `esignature-document-${payload.id}`}.pdf`;
const pdfBlob = new Blob([buffer], { type: "application/pdf" });
formData.append("jobid", jobid);
formData.append("file", pdfBlob, fileName);
try {
const imexMediaServerResponse = await axios.post(
normalizeUrl(`${localmediaserverhttp}/jobs/upload`),
formData,
options
);
if (imexMediaServerResponse.status === 200) {
//Succesful upload - we don't really need to do anything here.
} else {
throw new Error(
`Local media server upload failed with status ${imexMediaServerResponse.status}: ${imexMediaServerResponse.statusText}`
);
}
} catch (error) {
logger.log(`esig-webhook-lms-upload-error`, "ERROR", "redis", "api", {
message: error.message,
stack: error.stack,
jobid,
documentId: payload.id
});
notifyUploadFailure();
throw error;
}
} else {
try {
const uploadResult = await uploadFileBuffer({ key, buffer, contentType: "application/pdf" });
if (uploadResult.success) {
logger.log(`esig-webhook-s3-upload-success`, "INFO", "redis", "api", {
jobid: jobid,
documentId: payload.id,
s3Key: key,
bucket: uploadResult.bucket
});
//insert the document record with the s3 key and bucket info.
await client.request(INSERT_ESIGNATURE_COMPLETED_DOCOUMENT, {
docInput: {
jobid: jobs_by_pk.id,
uploaded_by: uploaded_by,
key,
type: "application/pdf",
extension: "pdf",
bodyshopid: jobs_by_pk.bodyshop.id,
size: buffer.length,
takenat: new Date().toISOString()
}
});
} else {
const uploadError = new Error(uploadResult.message || "S3 upload failed");
uploadError.stack = uploadResult.stack || uploadError.stack;
throw uploadError;
}
} catch (error) {
logger.log(`esig-webhook-s3-upload-error`, "ERROR", "redis", "api", {
message: error.message,
stack: error.stack,
jobid: jobid,
documentId: payload.id
});
notifyUploadFailure();
throw error;
}
}
//Update the audit trail and records to mark the document as completed.
await client.request(DISTRIBUTE_ESIGNATURE_DOCUMENT, {
external_document_id: payload.id.toString(),
esig_update: {
status: "COMPLETED",
completed: true,
completed_at: new Date().toISOString()
},
audit: {
jobid: jobs_by_pk.id,
bodyshopid: jobs_by_pk.bodyshop.id,
operation: `Esignature document with title ${payload.title} (ID: ${payload.documentMeta.id}) has been completed.`,
useremail: uploaded_by,
type: "esig-complete"
}
});
dispatchEsignNotificationSafely({
promise: dispatchEsignDocumentCompletedNotification({
jobId: jobs_by_pk.id,
documentId: payload.id.toString(),
title: payload.title,
uploadedBy: uploaded_by,
logger
}),
eventName: webhookTypeEnums.DOCUMENT_COMPLETED,
jobid,
documentId: payload.id.toString()
});
} catch (error) {
logger.log(`esig-webhook-event-completed-error`, "ERROR", "redis", "api", {
message: error.message,
stack: error.stack,
payload
});
throw error;
}
}
module.exports = {
esignWebhook
};

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