Compare commits

...

86 Commits

Author SHA1 Message Date
Dave
665f09d832 feature/IO-3614-March-2026-Tech-Debt - GraphQL-Request backend package bump 2026-03-16 11:13:15 -04:00
Dave
3d7f2961fd Tests + Packages + Vite 2026-03-16 11:02:48 -04:00
Dave Richer
af52c35013 Merged in feature/IO-2433-esignature (pull request #3133)
Feature/IO-2433 esignature
2026-03-14 01:17:41 +00:00
Dave Richer
36157d87bb Merged in feature/IO-3587-Commision-Cut (pull request #3132)
Feature/IO-3587 Commision Cut
2026-03-14 01:14:43 +00:00
Dave
722375fede feature/IO-3587-Commision-Cut - Remove some unrequired cleanup to reduce risk 2026-03-13 21:13:43 -04:00
Dave
339c19a041 Merge branch 'master-AIO' into feature/IO-3587-Commision-Cut 2026-03-13 21:05:36 -04:00
Dave Richer
8af8c8039c Merged in release/2026-03-13 (pull request #3131)
Release/2026-03-13 into master-AIO - IO-3571, IO-3582, IO-3584, IO-3590, IO-3592, IO-3596, IO-3600, IO-3601, IO-3603, IO-3604, IO-3605, IO-3606, IO-3607, IO-3610
2026-03-14 00:58:46 +00:00
Dave Richer
b8570f3ae9 Merged in release/2026-03-13 (pull request #3130)
IO-3610 Export Log DMS Bug
2026-03-13 03:06:47 +00:00
Allan Carr
6ef56f97c0 IO-2433 Missing Translation
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-12 16:58:59 -07:00
Dave
3a1d10b0d1 Merge remote-tracking branch 'origin/feature/IO-3610-Export-Log-DMS-Bug' into release/2026-03-13 2026-03-12 19:49:56 -04:00
Dave
dd633cea89 hotfix/2026-03-12 - Be more specific on CDK error passing, resolve circular dependency 2026-03-12 19:45:55 -04:00
Allan Carr
e6071709be IO-3610 Export Log DMS Bug
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-12 16:28:14 -07:00
Dave Richer
fb863c7979 Merged in release/2026-03-13 (pull request #3126)
IO-3585 saleClassValue fix
2026-03-12 20:34:21 +00:00
Allan Carr
c95c11fd0e Merged in hotfix/2026-03-12 (pull request #3125)
IO-3585 saleClassValue fix
2026-03-12 19:01:16 +00:00
Allan Carr
1351fbb814 Merged in feature/IO-3585-Fortellis-Insert-and-Update-Vehicle (pull request #3123)
IO-3585 saleClassValue fix
2026-03-12 18:58:35 +00:00
Allan Carr
dcd3a078ef IO-3585 saleClassValue fix
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-12 11:58:40 -07:00
Allan Carr
bb8e140f6e Merged in feature/IO-3585-Fortellis-Insert-and-Update-Vehicle (pull request #3122)
IO-3585 saleClassValue fix
2026-03-12 18:57:07 +00:00
Dave Richer
8102fd5177 Merged in release/2026-03-13 (pull request #3120)
Release/2026 03 13
2026-03-12 16:39:36 +00:00
Dave Richer
bf11e10676 Merged in hotfix/2026-03-12 (pull request #3119)
hotfix/2026-03-12 - Be more specific on CDK error passing, resolve circular dependency
2026-03-12 16:38:15 +00:00
Dave Richer
92e6bdf2a2 Merged in hotfix/2026-03-12 (pull request #3117)
hotfix/2026-03-12 - Be more specific on CDK error passing, resolve circular dependency
2026-03-12 16:37:23 +00:00
Allan Carr
a02e336d73 Merged in feature/IO-3584-Duplicate-Job-with-Full-Rates (pull request #3116)
IO-3584 Duplicate Job with Full Rates

Approved-by: Dave Richer
2026-03-12 16:34:40 +00:00
Dave
7ec8a73c30 hotfix/2026-03-12 - Be more specific on CDK error passing, resolve circular dependency 2026-03-12 12:33:54 -04:00
Dave
c7bb1a9c32 feature/IO-3587-Comission-Cut - Implement 2026-03-12 12:19:34 -04:00
Allan Carr
e669c19b98 IO-3584 Duplicate Job with Full Rates
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-11 18:48:23 -07:00
Allan Carr
5c55c0c74b Merged in feature/IO-3582-Add-Return-From-Invoice-to-Order-Table (pull request #3114)
IO-3582 Add Return From Inv to Parts Return Table

Approved-by: Dave Richer
2026-03-11 13:52:43 +00:00
Allan Carr
f1f705903a IO-3582 Add Return From Inv to Parts Return Table
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-10 20:02:32 -07:00
Allan Carr
6551be2d92 Merged in feature/IO-3606-Tech-Console-Ticket-Date (pull request #3111)
IO-3606 Tech Console Job Clock In Ticket Date

Approved-by: Dave Richer
2026-03-10 15:32:36 +00:00
Allan Carr
48e59fe849 Merged in feature/IO-3607-Employee-Drop-Down-Inactive (pull request #3110)
IO-3607 Employee Drop Down Inactive filter

Approved-by: Dave Richer
2026-03-10 15:32:14 +00:00
Allan Carr
7991192496 Merged in feature/IO-3592-WIP-Summary-Reports (pull request #3112)
IO-3592 WIP Summary Reports

Approved-by: Dave Richer
2026-03-10 15:31:57 +00:00
Allan Carr
05cd60c2a1 IO-3592 WIP Summary Reports
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-09 17:57:55 -07:00
Allan Carr
26fc76a767 IO-3606 Tech Console Job Clock In Ticket Date
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-09 17:42:13 -07:00
Allan Carr
49816d5d43 IO-3607 Employee Drop Down Inactive filter
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-09 16:28:10 -07:00
Dave Richer
b9b3e2c2aa Merged in hotfix/2026-03-09 (pull request #3109)
hotfix/2026-03-09 - Eula
2026-03-09 17:00:49 +00:00
Allan Carr
e3c02f94f1 Merged in feature/IO-3603-Production-Board-Note-Autofocus (pull request #3102)
IO-3603 Production Board Note Autofocus

Approved-by: Dave Richer
2026-03-09 16:59:58 +00:00
Allan Carr
490dd662d5 Merged in feature/IO-3571-Create-Job-Done-Loading (pull request #3105)
IO-3571 Create Job Done Button Loading

Approved-by: Dave Richer
2026-03-09 16:59:22 +00:00
Dave
8d00fc29d1 feature/IO-3603-Production-Board-Note-Autofocus - Fix 2026-03-09 12:59:00 -04:00
Dave
784378a999 feature/IO-3571-Create-Job-Done-Loading - Fix set is submitting 2026-03-09 12:53:59 -04:00
Allan Carr
f04f48f593 Merged in feature/IO-3605-Material-Threshold-Calculations (pull request #3103)
IO-3605 Material Threshold Calculations

Approved-by: Dave Richer
2026-03-09 16:36:35 +00:00
Allan Carr
721e9bc464 Merged in feature/IO-3590-Admin-Save-Buttons (pull request #3104)
IO-3590 Admin Save Buttons

Approved-by: Dave Richer
2026-03-09 16:35:55 +00:00
Dave Richer
76c828a1c9 Merged in hotfix/2026-03-09 (pull request #3106)
hotfix/2026-03-09 - Eula
2026-03-09 16:33:45 +00:00
Dave
7e5363f911 hotfix/2026-03-09 - Eula 2026-03-09 12:33:20 -04:00
Allan Carr
0d502d4dd4 IO-3571 Create Job Done Button Loading
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-06 18:31:22 -08:00
Allan Carr
f5b16394f9 IO-3590 Admin Save Buttons
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-06 18:16:36 -08:00
Allan Carr
7132465945 IO-3605 Material Threshold Calculations
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-06 17:56:21 -08:00
Allan Carr
a873a2573a IO-3603 Production Board Note Autofocus
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-06 15:32:06 -08:00
Allan Carr
ff24db6561 Merged in feature/IO-3596-Manual-Line-Lock-Down (pull request #3100)
IO-3596 Manual Line Lock Down

Approved-by: Dave Richer
2026-03-06 22:23:03 +00:00
Allan Carr
da26954c3b IO-3596 Manual Line Lock Down
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-06 14:21:00 -08:00
Allan Carr
6991cf60e5 Merged in feature/IO-3604-Tech-Job-Drawer (pull request #3098)
IO-3604 Tech Job Drawer
2026-03-06 20:41:23 +00:00
Allan Carr
818aedf04f IO-3604 Tech Job Drawer
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-06 12:43:12 -08:00
Allan Carr
1cb6834207 Merged in feature/IO-3600-Job-Line-Close-Select-Box-Filter (pull request #3096)
IO-3600 Job Line Close Select Box Filter

Approved-by: Dave Richer
2026-03-06 19:16:18 +00:00
Allan Carr
8577929bd4 IO-3600 Job Line Close Select Box Filter
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-06 11:17:27 -08:00
Allan Carr
f44121e06b Merged in feature/IO-3601-QBO-Logging (pull request #3095)
IO-3601 Additional QBO Logging

Approved-by: Dave Richer
2026-03-06 18:32:16 +00:00
Allan Carr
faf9fb75c5 IO-3601 Additional QBO Logging
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-05 18:01:53 -08: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
Dave Richer
8980d3716b Merged in release/2026-03-13 (pull request #3092)
release/2026-02-27 - Final RR debug fix [FRONT END NOT REQUIRED]

Approved-by: Allan Carr
2026-03-04 20:31:22 +00:00
Dave Richer
764ec5f8f9 Merged in release/2026-02-27 (pull request #3091)
release/2026-02-27 - Final RR debug fix
2026-03-04 20:20:49 +00:00
Dave
a7a7551dae release/2026-02-27 - Final RR debug fix 2026-03-04 15:17:56 -05:00
Dave Richer
571536a7ec Merged in master-AIO (pull request #3089)
release/2026-02-27 - bump
2026-03-04 19:28:47 +00:00
Dave Richer
20e56fff6a Merged in release/2026-02-27 (pull request #3088)
release/2026-02-27 - bump
2026-03-04 19:28:21 +00:00
Dave
8f132ca14d release/2026-02-27 - bump 2026-03-04 14:27:23 -05:00
Dave Richer
99c002dac1 Merged in master-AIO (pull request #3086)
Master AIO
2026-03-04 19:24:37 +00:00
Dave Richer
0cd30ccdec Merged in release/2026-02-27 (pull request #3085)
Release/2026 02 27
2026-03-04 17:45:15 +00:00
Patrick Fic
acd69276a5 Merged in release/revert-revert-pr-3080 (pull request #3083)
Revert "Revert "Release/2026 02 27 (pull request #3070)" (pull request #3080)"
2026-03-04 17:41:44 +00:00
Patrick Fic
faf5878bdf Revert "Revert "Release/2026 02 27 (pull request #3070)" (pull request #3080)" 2026-03-04 17:41:10 +00:00
Dave
f56a540b2f release/2026-02-27 - Fix Time ticket issue, add additional logging around reynolds 2026-03-04 12:21:29 -05:00
Dave
e251e5f8f6 release/2026-02-27 - Disable Responsive Design 2026-03-04 11:38:33 -05:00
Patrick Fic
5a55798d2d Merged in release/revert-pr-3070-2026-03-04 (pull request #3080)
Revert "Release/2026 02 27 (pull request #3070)"
2026-03-04 16:20:15 +00:00
Patrick Fic
c9e41ba72a Revert "Release/2026 02 27 (pull request #3070)" 2026-03-04 16:18:44 +00:00
Dave Richer
522f2b9e26 Merged in release/2026-02-27 (pull request #3070)
Release/2026 02 27
2026-03-04 01:41:53 +00:00
Allan Carr
be9267ddd4 Merged in feature/IO-3594-Kaizen-Datapump-Enhancement (pull request #3076)
Feature/IO-3594 Kaizen Datapump Enhancement

Approved-by: Dave Richer
2026-03-04 00:51:11 +00:00
Patrick Fic
e4a79b51c7 Merged in feature/IO-3515-ocr-bill-posting (pull request #3077)
Feature/IO-3515 ocr bill posting
2026-03-03 22:56:52 +00:00
Patrick Fic
47a9a963fa IO-3515 Minor improvements to Bill AI. 2026-03-03 14:56:28 -08:00
Allan Carr
f3c7a831a1 IO-3594 Kaizen Datapump Enhancement
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-03 13:18:16 -08:00
Allan Carr
6ac9310e81 IO-3594 Kaizen Datapump Enhancement
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-03 13:17:56 -08:00
Dave
b91e65be0e release/2026-02-27 - Add gating 2026-03-03 15:25:13 -05:00
Dave
3f2358e30c Merge remote-tracking branch 'origin/hotfix/2026-03-03' into release/2026-02-27 2026-03-03 13:08:31 -05:00
Dave Richer
ce02d90c3c Merged in hotfix/2026-03-03-RR-logging-Posting-Enhancements (pull request #3072)
hotfix/2026-03-03-RR-logging-Posting-Enhancements - Implement
2026-03-03 18:05:06 +00:00
Allan Carr
95a71bea6e Merged in feature/IO-3585-Fortellis-Insert-and-Update-Vehicle (pull request #3071)
IO-3585 Fortellis Insert and Update Vehicle Info

Approved-by: Dave Richer
2026-03-03 18:03:41 +00:00
Dave
3b27120d77 hotfix/2026-03-03-RR-logging-Posting-Enhancements - Implement 2026-03-03 13:03:02 -05:00
Dave Richer
f350163056 Merged in feature/IO-3554-Form-Row-Layout (pull request #3068)
feature/IO-3554-Form-Row-Layout - dial in tables
2026-03-02 17:00:21 +00:00
Dave Richer
57cfecb7b8 Merged in feature/IO-3554-Form-Row-Layout (pull request #3066)
feature/IO-3554-Form-Row-Layout - Modify how truncation works on responsive tables.
2026-03-02 16:29:45 +00: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
Patrick Fic
5f8a08b0a7 IO-3515 Limit logging. 2026-02-23 11:55:22 -08:00
103 changed files with 7232 additions and 5141 deletions

3716
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,59 +2,60 @@
"name": "bodyshop",
"version": "0.2.1",
"engines": {
"node": ">=22.0.0"
"node": ">=22.12.0"
},
"type": "module",
"private": true,
"proxy": "http://localhost:4000",
"dependencies": {
"@amplitude/analytics-browser": "^2.35.3",
"@amplitude/analytics-browser": "^2.36.5",
"@ant-design/pro-layout": "^7.22.6",
"@apollo/client": "^4.1.6",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@documenso/embed-react": "^0.6.0",
"@emotion/is-prop-valid": "^1.4.0",
"@fingerprintjs/fingerprintjs": "^5.0.1",
"@firebase/analytics": "^0.10.19",
"@firebase/app": "^0.14.8",
"@firebase/auth": "^1.12.0",
"@firebase/firestore": "^4.11.0",
"@firebase/messaging": "^0.12.22",
"@fingerprintjs/fingerprintjs": "^5.1.0",
"@firebase/analytics": "^0.10.20",
"@firebase/app": "^0.14.9",
"@firebase/auth": "^1.12.1",
"@firebase/firestore": "^4.12.0",
"@firebase/messaging": "^0.12.24",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.11.2",
"@sentry/cli": "^3.2.2",
"@sentry/react": "^10.40.0",
"@sentry/cli": "^3.3.3",
"@sentry/react": "^10.43.0",
"@sentry/vite-plugin": "^4.9.1",
"@splitsoftware/splitio-react": "^2.6.1",
"@tanem/react-nprogress": "^5.0.63",
"antd": "^6.3.1",
"antd": "^6.3.3",
"apollo-link-logger": "^3.0.0",
"autosize": "^6.0.1",
"axios": "^1.13.5",
"axios": "^1.13.6",
"classnames": "^2.5.1",
"css-box-model": "^1.2.1",
"dayjs": "^1.11.19",
"dayjs": "^1.11.20",
"dayjs-business-days2": "^1.3.2",
"dinero.js": "^1.9.1",
"dotenv": "^17.3.1",
"env-cmd": "^11.0.0",
"exifr": "^7.1.3",
"graphql": "^16.13.0",
"graphql": "^16.13.1",
"graphql-ws": "^6.0.7",
"i18next": "^25.8.13",
"i18next": "^25.8.18",
"i18next-browser-languagedetector": "^8.2.1",
"immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.38",
"lightningcss": "^1.31.1",
"logrocket": "^12.0.0",
"libphonenumber-js": "^1.12.40",
"lightningcss": "^1.32.0",
"logrocket": "^12.1.0",
"markerjs2": "^2.32.7",
"memoize-one": "^6.0.0",
"normalize-url": "^8.1.1",
"object-hash": "^3.0.0",
"phone": "^3.1.71",
"posthog-js": "^1.355.0",
"posthog-js": "^1.360.2",
"prop-types": "^15.8.1",
"query-string": "^9.3.1",
"raf-schd": "^4.0.3",
@@ -65,8 +66,8 @@
"react-dom": "^19.2.4",
"react-grid-gallery": "^1.0.1",
"react-grid-layout": "^2.2.2",
"react-i18next": "^16.5.4",
"react-icons": "^5.5.0",
"react-i18next": "^16.5.8",
"react-icons": "^5.6.0",
"react-image-lightbox": "^5.1.4",
"react-markdown": "^10.1.0",
"react-number-format": "^5.4.3",
@@ -76,8 +77,8 @@
"react-resizable": "^3.1.3",
"react-router-dom": "^7.13.1",
"react-sticky": "^6.0.3",
"react-virtuoso": "^4.18.1",
"recharts": "^3.7.0",
"react-virtuoso": "^4.18.3",
"recharts": "^3.8.0",
"redux": "^5.0.1",
"redux-actions": "^3.0.3",
"redux-persist": "^6.0.0",
@@ -85,7 +86,7 @@
"redux-state-sync": "^3.1.4",
"reselect": "^5.1.1",
"rxjs": "^7.8.2",
"sass": "^1.97.3",
"sass": "^1.98.0",
"socket.io-client": "^4.8.3",
"styled-components": "^6.3.11",
"vite-plugin-ejs": "^1.7.0",
@@ -140,15 +141,16 @@
"@ant-design/icons": "^6.1.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.28.5",
"@dotenvx/dotenvx": "^1.52.0",
"@dotenvx/dotenvx": "^1.55.1",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.39.2",
"@playwright/test": "^1.58.2",
"@rolldown/plugin-babel": "^0.2.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0",
"browserslist": "^4.28.1",
"browserslist-to-esbuild": "^2.1.1",
@@ -156,21 +158,18 @@
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"globals": "^17.3.0",
"globals": "^17.4.0",
"jsdom": "^28.1.0",
"memfs": "^4.56.10",
"memfs": "^4.56.11",
"os-browserify": "^0.3.0",
"playwright": "^1.58.2",
"react-error-overlay": "^6.1.0",
"redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.3",
"vite": "^7.3.1",
"vite-plugin-babel": "^1.5.1",
"vite": "^8.0.0",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-node-polyfills": "^0.25.0",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-style-import": "^2.0.0",
"vitest": "^4.0.18",
"vitest": "^4.1.0",
"workbox-window": "^7.4.0"
}
}

View File

@@ -0,0 +1,184 @@
import { ApolloProvider } from "@apollo/client/react";
import * as Sentry from "@sentry/react";
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
import { ConfigProvider, Grid } from "antd";
import enLocale from "antd/es/locale/en_US";
import { useEffect, useMemo } from "react";
import { CookiesProvider } from "react-cookie";
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
import { setDarkMode } from "../redux/application/application.actions";
import { selectDarkMode } from "../redux/application/application.selectors";
import { selectCurrentUser } from "../redux/user/user.selectors.js";
import { signOutStart } from "../redux/user/user.actions";
import client from "../utils/GraphQLClient";
import App from "./App";
import getTheme from "./themeProvider";
// Base Split configuration
const config = {
core: {
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
key: "anon"
}
};
function SplitClientProvider({ children }) {
const imexshopid = useSelector((state) => state.user.imexshopid);
const splitClient = useSplitClient({ key: imexshopid || "anon" });
useEffect(() => {
if (import.meta.env.DEV && splitClient && imexshopid) {
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
}
}, [splitClient, imexshopid]);
return children;
}
function AppContainer() {
const { t } = useTranslation();
const dispatch = useDispatch();
const currentUser = useSelector(selectCurrentUser);
const isDarkMode = useSelector(selectDarkMode);
const screens = Grid.useBreakpoint();
const isPhone = !screens.md;
const isUltraWide = Boolean(screens.xxxl);
const theme = useMemo(() => {
const baseTheme = getTheme(isDarkMode);
return {
...baseTheme,
token: {
...(baseTheme.token || {}),
screenXXXL: 2160
},
components: {
...(baseTheme.components || {}),
Table: {
...(baseTheme.components?.Table || {}),
cellFontSizeSM: isPhone ? 12 : 13,
cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14,
cellFontSize: isUltraWide ? 15 : 14,
cellPaddingInlineSM: isPhone ? 8 : 10,
cellPaddingInlineMD: isPhone ? 10 : 14,
cellPaddingInline: isUltraWide ? 20 : 16,
cellPaddingBlockSM: isPhone ? 8 : 10,
cellPaddingBlockMD: isPhone ? 10 : 12,
cellPaddingBlock: isUltraWide ? 14 : 12,
selectionColumnWidth: isPhone ? 44 : 52
}
}
};
}, [isDarkMode, isPhone, isUltraWide]);
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []);
const antdPagination = useMemo(
() => ({
showSizeChanger: !isPhone,
totalBoundaryShowSizeChanger: 100
}),
[isPhone]
);
const antdForm = useMemo(
() => ({
validateMessages: {
required: t("general.validation.required", { label: "${label}" })
}
}),
[t]
);
// Global seamless logout listener with redirect to /signin
useEffect(() => {
const handleSeamlessLogout = (event) => {
if (event.data?.type !== "seamlessLogoutRequest") return;
// Only accept messages from the parent window
if (event.source !== window.parent) return;
const targetOrigin = event.origin || "*";
if (currentUser?.authorized !== true) {
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin);
return;
}
dispatch(signOutStart());
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin);
};
window.addEventListener("message", handleSeamlessLogout);
return () => {
window.removeEventListener("message", handleSeamlessLogout);
};
}, [dispatch, currentUser?.authorized]);
// Update data-theme attribute (no cleanup to avoid transient style churn)
useEffect(() => {
document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
}, [isDarkMode]);
// Sync darkMode with localStorage
useEffect(() => {
const uid = currentUser?.uid;
if (!uid) {
dispatch(setDarkMode(false));
return;
}
const key = `dark-mode-${uid}`;
const raw = localStorage.getItem(key);
if (raw == null) {
dispatch(setDarkMode(false));
return;
}
try {
dispatch(setDarkMode(Boolean(JSON.parse(raw))));
} catch {
dispatch(setDarkMode(false));
}
}, [currentUser?.uid, dispatch]);
// Persist darkMode
useEffect(() => {
const uid = currentUser?.uid;
if (!uid) return;
localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode));
}, [isDarkMode, currentUser?.uid]);
return (
<CookiesProvider>
<ApolloProvider client={client}>
<ConfigProvider
input={antdInput}
locale={enLocale}
theme={theme}
form={antdForm}
table={antdTable}
pagination={antdPagination}
componentSize={isPhone ? "small" : isUltraWide ? "large" : "middle"}
popupOverflow="viewport"
>
<GlobalLoadingBar />
<SplitFactoryProvider config={config}>
<SplitClientProvider>
<App />
</SplitClientProvider>
</SplitFactoryProvider>
</ConfigProvider>
</ApolloProvider>
</CookiesProvider>
);
}
export default Sentry.withProfiler(AppContainer);

View File

@@ -1,7 +1,7 @@
import { ApolloProvider } from "@apollo/client/react";
import * as Sentry from "@sentry/react";
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
import { ConfigProvider, Grid } from "antd";
import { ConfigProvider } from "antd";
import enLocale from "antd/es/locale/en_US";
import { useEffect, useMemo } from "react";
import { CookiesProvider } from "react-cookie";
@@ -43,47 +43,10 @@ function AppContainer() {
const currentUser = useSelector(selectCurrentUser);
const isDarkMode = useSelector(selectDarkMode);
const screens = Grid.useBreakpoint();
const isPhone = !screens.md;
const isUltraWide = Boolean(screens.xxxl);
const theme = useMemo(() => {
const baseTheme = getTheme(isDarkMode);
return {
...baseTheme,
token: {
...(baseTheme.token || {}),
screenXXXL: 2160
},
components: {
...(baseTheme.components || {}),
Table: {
...(baseTheme.components?.Table || {}),
cellFontSizeSM: isPhone ? 12 : 13,
cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14,
cellFontSize: isUltraWide ? 15 : 14,
cellPaddingInlineSM: isPhone ? 8 : 10,
cellPaddingInlineMD: isPhone ? 10 : 14,
cellPaddingInline: isUltraWide ? 20 : 16,
cellPaddingBlockSM: isPhone ? 8 : 10,
cellPaddingBlockMD: isPhone ? 10 : 12,
cellPaddingBlock: isUltraWide ? 14 : 12,
selectionColumnWidth: isPhone ? 44 : 52
}
}
};
}, [isDarkMode, isPhone, isUltraWide]);
const theme = useMemo(() => getTheme(isDarkMode), [isDarkMode]);
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []);
const antdPagination = useMemo(
() => ({
showSizeChanger: !isPhone,
totalBoundaryShowSizeChanger: 100
}),
[isPhone]
);
const antdForm = useMemo(
() => ({
@@ -159,16 +122,7 @@ function AppContainer() {
return (
<CookiesProvider>
<ApolloProvider client={client}>
<ConfigProvider
input={antdInput}
locale={enLocale}
theme={theme}
form={antdForm}
table={antdTable}
pagination={antdPagination}
componentSize={isPhone ? "small" : isUltraWide ? "large" : "middle"}
popupOverflow="viewport"
>
<ConfigProvider input={antdInput} locale={enLocale} theme={theme} form={antdForm}>
<GlobalLoadingBar />
<SplitFactoryProvider config={config}>
<SplitClientProvider>

View File

@@ -0,0 +1,184 @@
import { ApolloProvider } from "@apollo/client/react";
import * as Sentry from "@sentry/react";
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
import { ConfigProvider, Grid } from "antd";
import enLocale from "antd/es/locale/en_US";
import { useEffect, useMemo } from "react";
import { CookiesProvider } from "react-cookie";
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
import { setDarkMode } from "../redux/application/application.actions";
import { selectDarkMode } from "../redux/application/application.selectors";
import { selectCurrentUser } from "../redux/user/user.selectors.js";
import { signOutStart } from "../redux/user/user.actions";
import client from "../utils/GraphQLClient";
import App from "./App";
import getTheme from "./themeProvider";
// Base Split configuration
const config = {
core: {
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
key: "anon"
}
};
function SplitClientProvider({ children }) {
const imexshopid = useSelector((state) => state.user.imexshopid);
const splitClient = useSplitClient({ key: imexshopid || "anon" });
useEffect(() => {
if (import.meta.env.DEV && splitClient && imexshopid) {
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
}
}, [splitClient, imexshopid]);
return children;
}
function AppContainer() {
const { t } = useTranslation();
const dispatch = useDispatch();
const currentUser = useSelector(selectCurrentUser);
const isDarkMode = useSelector(selectDarkMode);
const screens = Grid.useBreakpoint();
const isPhone = !screens.md;
const isUltraWide = Boolean(screens.xxxl);
const theme = useMemo(() => {
const baseTheme = getTheme(isDarkMode);
return {
...baseTheme,
token: {
...(baseTheme.token || {}),
screenXXXL: 2160
},
components: {
...(baseTheme.components || {}),
Table: {
...(baseTheme.components?.Table || {}),
cellFontSizeSM: isPhone ? 12 : 13,
cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14,
cellFontSize: isUltraWide ? 15 : 14,
cellPaddingInlineSM: isPhone ? 8 : 10,
cellPaddingInlineMD: isPhone ? 10 : 14,
cellPaddingInline: isUltraWide ? 20 : 16,
cellPaddingBlockSM: isPhone ? 8 : 10,
cellPaddingBlockMD: isPhone ? 10 : 12,
cellPaddingBlock: isUltraWide ? 14 : 12,
selectionColumnWidth: isPhone ? 44 : 52
}
}
};
}, [isDarkMode, isPhone, isUltraWide]);
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []);
const antdPagination = useMemo(
() => ({
showSizeChanger: !isPhone,
totalBoundaryShowSizeChanger: 100
}),
[isPhone]
);
const antdForm = useMemo(
() => ({
validateMessages: {
required: t("general.validation.required", { label: "${label}" })
}
}),
[t]
);
// Global seamless logout listener with redirect to /signin
useEffect(() => {
const handleSeamlessLogout = (event) => {
if (event.data?.type !== "seamlessLogoutRequest") return;
// Only accept messages from the parent window
if (event.source !== window.parent) return;
const targetOrigin = event.origin || "*";
if (currentUser?.authorized !== true) {
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin);
return;
}
dispatch(signOutStart());
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin);
};
window.addEventListener("message", handleSeamlessLogout);
return () => {
window.removeEventListener("message", handleSeamlessLogout);
};
}, [dispatch, currentUser?.authorized]);
// Update data-theme attribute (no cleanup to avoid transient style churn)
useEffect(() => {
document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
}, [isDarkMode]);
// Sync darkMode with localStorage
useEffect(() => {
const uid = currentUser?.uid;
if (!uid) {
dispatch(setDarkMode(false));
return;
}
const key = `dark-mode-${uid}`;
const raw = localStorage.getItem(key);
if (raw == null) {
dispatch(setDarkMode(false));
return;
}
try {
dispatch(setDarkMode(Boolean(JSON.parse(raw))));
} catch {
dispatch(setDarkMode(false));
}
}, [currentUser?.uid, dispatch]);
// Persist darkMode
useEffect(() => {
const uid = currentUser?.uid;
if (!uid) return;
localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode));
}, [isDarkMode, currentUser?.uid]);
return (
<CookiesProvider>
<ApolloProvider client={client}>
<ConfigProvider
input={antdInput}
locale={enLocale}
theme={theme}
form={antdForm}
table={antdTable}
pagination={antdPagination}
componentSize={isPhone ? "small" : isUltraWide ? "large" : "middle"}
popupOverflow="viewport"
>
<GlobalLoadingBar />
<SplitFactoryProvider config={config}>
<SplitClientProvider>
<App />
</SplitClientProvider>
</SplitFactoryProvider>
</ConfigProvider>
</ApolloProvider>
</CookiesProvider>
);
}
export default Sentry.withProfiler(AppContainer);

View File

@@ -443,38 +443,62 @@
flex-direction: column;
}
/* DMS top panels: prevent card/table overflow into adjacent column at desktop+zoom */
.dms-top-panel-col {
min-width: 0;
}
.dms-top-panel-col > .ant-card {
width: 100%;
min-width: 0;
max-width: 100%;
}
.dms-top-panel-col > .ant-card .ant-card-body {
min-width: 0;
max-width: 100%;
}
.dms-top-panel-col .ant-table-wrapper,
.dms-top-panel-col .ant-tabs,
.dms-top-panel-col .ant-tabs-content,
.dms-top-panel-col .ant-tabs-tabpane {
min-width: 0;
max-width: 100%;
}
//.rbc-time-header-gutter {
// padding: 0;
//}
/* globally allow shrink inside table cells */
.prod-list-table .ant-table-cell,
.prod-list-table .ant-table-cell > * {
min-width: 0;
}
/* common AntD offenders */
.prod-list-table > .ant-table-cell .ant-space,
.ant-table-cell .ant-space-item {
min-width: 0;
}
/* Keep your custom header content on the left, push AntD sorter to the far right */
.prod-list-table .ant-table-column-sorters {
display: flex !important;
align-items: center;
width: 100%;
}
.prod-list-table .ant-table-column-title {
flex: 1 1 auto;
min-width: 0; /* allows ellipsis to work */
}
.prod-list-table .ant-table-column-sorter {
margin-left: auto;
flex: 0 0 auto;
}
///* globally allow shrink inside table cells */
//.prod-list-table .ant-table-cell,
//.prod-list-table .ant-table-cell > * {
// min-width: 0;
//}
//
///* common AntD offenders */
//.prod-list-table > .ant-table-cell .ant-space,
//.ant-table-cell .ant-space-item {
// min-width: 0;
//}
//
///* Keep your custom header content on the left, push AntD sorter to the far right */
//.prod-list-table .ant-table-column-sorters {
// display: flex !important;
// align-items: center;
// width: 100%;
//}
//
//.prod-list-table .ant-table-column-title {
// flex: 1 1 auto;
// min-width: 0; /* allows ellipsis to work */
//}
//
//.prod-list-table .ant-table-column-sorter {
// margin-left: auto;
// flex: 0 0 auto;
//}
.global-search-autocomplete-fix {
@@ -485,3 +509,10 @@
pointer-events: none !important;
}
}
.esignature-embed {
width: 100%;
height: 100%;
border-width: 0;
}

View File

@@ -108,7 +108,7 @@ function BillEnterAiScan({
setIsAiScan(true);
const formdata = new FormData();
formdata.append("billScan", file);
formdata.append("jobid", billEnterModal.context.job?.id);
formdata.append("jobid", form.getFieldValue("jobid") || billEnterModal.context.job?.id);
formdata.append("bodyshopid", bodyshop.id);
formdata.append("partsorderid", billEnterModal.context.parts_order?.id);

View File

@@ -4,6 +4,7 @@ import dayjs from "../../utils/day";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectDarkMode } from "../../redux/application/application.selectors.js";
import { useTranslation } from "react-i18next";
const mapStateToProps = createStructuredSelector({
isDarkMode: selectDarkMode
@@ -19,25 +20,35 @@ export function DmsLogEvents({
detailsNonce,
isDarkMode,
colorizeJson = false,
showDetails = true
showDetails = true,
allowXmlPayload = true
}) {
const { t } = useTranslation();
const [openSet, setOpenSet] = useState(() => new Set());
const [copiedKey, setCopiedKey] = useState(null);
// Inject JSON highlight styles once (only when colorize is enabled)
useEffect(() => {
if (!colorizeJson) return;
if (typeof document === "undefined") return;
if (document.getElementById("json-highlight-styles")) return;
const style = document.createElement("style");
style.id = "json-highlight-styles";
let style = document.getElementById("json-highlight-styles");
if (!style) {
style = document.createElement("style");
style.id = "json-highlight-styles";
document.head.appendChild(style);
}
style.textContent = `
.json-key { color: #fa8c16; }
.json-string { color: #52c41a; }
.json-number { color: #722ed1; }
.json-boolean { color: #1890ff; }
.json-null { color: #faad14; }
.xml-tag { color: #1677ff; }
.xml-attr { color: #d46b08; }
.xml-value { color: #389e0d; }
.xml-decl { color: #7c3aed; }
.xml-comment { color: #8c8c8c; }
`;
document.head.appendChild(style);
}, [colorizeJson]);
// Trim openSet if logs shrink
@@ -65,6 +76,13 @@ export function DmsLogEvents({
// Only treat meta as "present" when we are allowed to show details
const hasMeta = !isEmpty(meta) && showDetails;
const isOpen = hasMeta && openSet.has(idx);
const xml = hasMeta && allowXmlPayload ? extractXmlFromMeta(meta) : { request: null, response: null };
const hasRequestXml = !!xml.request;
const hasResponseXml = !!xml.response;
const copyPayload = hasMeta ? getCopyPayload(meta) : null;
const copyPayloadKey = `copy-${idx}`;
const copyReqKey = `copy-req-${idx}`;
const copyResKey = `copy-res-${idx}`;
return {
key: idx,
@@ -92,10 +110,42 @@ export function DmsLogEvents({
return next;
})
}
style={{ cursor: "pointer", userSelect: "none" }}
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
>
{isOpen ? "Hide details" : "Details"}
{isOpen ? t("dms.labels.hide_details") : t("dms.labels.details")}
</a>
<Divider orientation="vertical" />
<a
role="button"
onClick={() => handleCopyAction(copyPayloadKey, copyPayload, setCopiedKey)}
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
>
{copiedKey === copyPayloadKey ? t("dms.labels.copied") : t("dms.labels.copy")}
</a>
{hasRequestXml && (
<>
<Divider orientation="vertical" />
<a
role="button"
onClick={() => handleCopyAction(copyReqKey, xml.request, setCopiedKey)}
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
>
{copiedKey === copyReqKey ? t("dms.labels.copied") : t("dms.labels.copy_request")}
</a>
</>
)}
{hasResponseXml && (
<>
<Divider orientation="vertical" />
<a
role="button"
onClick={() => handleCopyAction(copyResKey, xml.response, setCopiedKey)}
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
>
{copiedKey === copyResKey ? t("dms.labels.copied") : t("dms.labels.copy_response")}
</a>
</>
)}
</>
)}
</Space>
@@ -103,14 +153,30 @@ export function DmsLogEvents({
{/* Row 2: details body (only when open) */}
{hasMeta && isOpen && (
<div style={{ marginLeft: 6 }}>
<JsonBlock isDarkMode={isDarkMode} data={meta} colorize={colorizeJson} />
<JsonBlock isDarkMode={isDarkMode} data={removeXmlFromMeta(meta)} colorize={colorizeJson} />
{hasRequestXml && (
<XmlBlock
isDarkMode={isDarkMode}
title={t("dms.labels.request_xml")}
xmlText={xml.request}
colorize={colorizeJson}
/>
)}
{hasResponseXml && (
<XmlBlock
isDarkMode={isDarkMode}
title={t("dms.labels.response_xml")}
xmlText={xml.response}
colorize={colorizeJson}
/>
)}
</div>
)}
</Space>
)
};
}),
[logs, openSet, colorizeJson, isDarkMode, showDetails]
[logs, openSet, colorizeJson, copiedKey, isDarkMode, showDetails, allowXmlPayload, t]
);
return <Timeline reverse items={items} />;
@@ -179,6 +245,121 @@ const safeStringify = (obj, spaces = 2) => {
}
};
/**
* Get request/response XML from various Reynolds log meta shapes.
* @param meta
* @returns {{request: string|null, response: string|null}}
*/
const extractXmlFromMeta = (meta) => {
const request =
firstString(meta?.requestXml) ||
firstString(meta?.xml?.request) ||
firstString(meta?.response?.xml?.request) ||
firstString(meta?.response?.requestXml);
const response =
firstString(meta?.responseXml) || firstString(meta?.xml?.response) || firstString(meta?.response?.xml?.response);
return { request, response };
};
/**
* Return the value to copy when clicking the "Copy" action.
* @param meta
* @returns {*}
*/
const getCopyPayload = (meta) => {
if (meta?.payload != null) return meta.payload;
return meta;
};
/**
* Remove bulky XML fields from object shown in JSON block (XML is rendered separately).
* @param meta
* @returns {*}
*/
const removeXmlFromMeta = (meta) => {
if (meta == null || typeof meta !== "object") return meta;
const cloned = safeClone(meta);
if (cloned == null || typeof cloned !== "object") return meta;
if (typeof cloned.requestXml === "string") delete cloned.requestXml;
if (typeof cloned.responseXml === "string") delete cloned.responseXml;
if (cloned.xml && typeof cloned.xml === "object") {
if (typeof cloned.xml.request === "string") delete cloned.xml.request;
if (typeof cloned.xml.response === "string") delete cloned.xml.response;
if (isEmpty(cloned.xml)) delete cloned.xml;
}
if (cloned.response?.xml && typeof cloned.response.xml === "object") {
if (typeof cloned.response.xml.request === "string") delete cloned.response.xml.request;
if (typeof cloned.response.xml.response === "string") delete cloned.response.xml.response;
if (isEmpty(cloned.response.xml)) delete cloned.response.xml;
}
return cloned;
};
/**
* Safe deep clone for plain JSON structures.
* @param value
* @returns {*}
*/
const safeClone = (value) => {
try {
return JSON.parse(JSON.stringify(value));
} catch {
return value;
}
};
/**
* First non-empty string helper.
* @param value
* @returns {string|null}
*/
const firstString = (value) => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
};
/**
* Copy arbitrary text/object to clipboard.
* @param key
* @param value
* @param setCopied
* @returns {Promise<void>}
*/
const handleCopyAction = async (key, value, setCopied) => {
const text = typeof value === "string" ? value : safeStringify(value, 2);
if (!text) return;
const copied = await copyTextToClipboard(text);
if (!copied) return;
setCopied(key);
setTimeout(() => {
setCopied((prev) => (prev === key ? null : prev));
}, 1200);
};
/**
* Clipboard helper (modern async Clipboard API).
* @param text
* @returns {Promise<boolean>}
*/
const copyTextToClipboard = async (text) => {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
return false;
}
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
};
/**
* JSON display block with optional syntax highlighting.
* @param data
@@ -210,6 +391,105 @@ const JsonBlock = ({ data, colorize, isDarkMode }) => {
return <pre style={preStyle}>{jsonText}</pre>;
};
/**
* XML display block with normalized indentation.
* @param title
* @param xmlText
* @param isDarkMode
* @returns {JSX.Element}
* @constructor
*/
const XmlBlock = ({ title, xmlText, isDarkMode, colorize = false }) => {
const base = {
margin: "8px 0 0",
maxWidth: 720,
overflowX: "auto",
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 12,
lineHeight: 1.45,
padding: 8,
borderRadius: 6,
background: isDarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.04)",
border: isDarkMode ? "1px solid rgba(255,255,255,0.12)" : "1px solid rgba(0,0,0,0.08)",
color: isDarkMode ? "var(--card-text-fallback)" : "#141414",
whiteSpace: "pre"
};
return (
<div style={{ marginTop: 6 }}>
<div style={{ fontSize: 11, fontWeight: 600 }}>{title}</div>
{colorize ? (
<pre style={base} dangerouslySetInnerHTML={{ __html: highlightXml(formatXml(xmlText)) }} />
) : (
<pre style={base}>{formatXml(xmlText)}</pre>
)}
</div>
);
};
/**
* Basic XML pretty-printer.
* @param xml
* @returns {string}
*/
const formatXml = (xml) => {
if (typeof xml !== "string") return "";
const normalized = xml.replace(/\r\n/g, "\n").replace(/>\s*</g, ">\n<").trim();
const lines = normalized.split("\n");
let indent = 0;
const out = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) continue;
if (/^<\/[^>]+>/.test(line)) indent = Math.max(indent - 1, 0);
out.push(`${" ".repeat(indent)}${line}`);
const opens = (line.match(/<[^/!?][^>]*>/g) || []).length;
const closes = (line.match(/<\/[^>]+>/g) || []).length;
const selfClosing = (line.match(/<[^>]+\/>/g) || []).length;
const declaration = /^<\?xml/.test(line) ? 1 : 0;
indent += opens - closes - selfClosing - declaration;
if (indent < 0) indent = 0;
}
return out.join("\n");
};
/**
* Syntax highlight pretty-printed XML text for HTML display.
* @param xmlText
* @returns {string}
*/
const highlightXml = (xmlText) => {
const esc = String(xmlText || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const lines = esc.split("\n");
return lines
.map((line) => {
let out = line;
out = out.replace(/(&lt;!--[\s\S]*?--&gt;)/g, '<span class="xml-comment">$1</span>');
out = out.replace(/(&lt;\?xml[\s\S]*?\?&gt;)/g, '<span class="xml-decl">$1</span>');
out = out.replace(/(&lt;\/?)([A-Za-z_][\w:.-]*)([\s\S]*?)(\/?&gt;)/g, (_m, open, tag, attrs, close) => {
const coloredAttrs = attrs.replace(
/([A-Za-z_][\w:.-]*)(=)("[^"]*"|'[^']*'|&quot;[\s\S]*?&quot;|&apos;[\s\S]*?&apos;)/g,
'<span class="xml-attr">$1</span>$2<span class="xml-value">$3</span>'
);
return `${open}<span class="xml-tag">${tag}</span>${coloredAttrs}${close}`;
});
return out;
})
.join("\n");
};
/**
* Syntax highlight JSON text for HTML display.
* @param jsonText

View File

@@ -41,7 +41,7 @@ export function EmailOverlayComponent({ emailConfig, form, selectedMediaState, b
const emailsToMenu = {
items: [
...bodyshop.employees
.filter((e) => e.user_email)
.filter((e) => e.user_email && e.active === true)
.map((e, idx) => ({
key: idx,
label: `${e.first_name} ${e.last_name}`,
@@ -59,7 +59,7 @@ export function EmailOverlayComponent({ emailConfig, form, selectedMediaState, b
const menuCC = {
items: [
...bodyshop.employees
.filter((e) => e.user_email)
.filter((e) => e.user_email && e.active === true)
.map((e, idx) => ({
key: idx,
label: `${e.first_name} ${e.last_name}`,

View File

@@ -0,0 +1,78 @@
import { EmbedUpdateDocumentV1 } from "@documenso/embed-react";
import { Modal } 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 } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
esignatureModal: selectEsignature,
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("esignature"))
});
export function EsignatureModalContainer({ esignatureModal, toggleModalVisible, bodyshop }) {
const { t } = useTranslation();
const { open, context } = esignatureModal;
const { token, envelopeId, documentId, jobid } = context;
return (
<Modal
open={open}
title={t("jobs.labels.esignature")}
onOk={async () => {
try {
const distResult = await axios.post("/esign/distribute", {
documentId,
envelopeId,
jobid,
bodyshopid: bodyshop.id
});
console.log("Distribution result:", distResult);
toggleModalVisible();
} catch (error) {
console.error("Error distributing document:", error);
}
}}
onCancel={async () => {
try {
const cancelResult = await axios.post("/esign/delete", {
documentId,
envelopeId
});
console.log("Cancel result:", cancelResult);
toggleModalVisible();
} catch (error) {
console.error("Error cancelling document:", error);
}
}}
okButtonProps={{ title: "Distribute by Email" }}
width="90%"
destroyOnHidden
>
<div style={{ height: "600px", width: "100%" }}>
{token ? (
<EmbedUpdateDocumentV1
presignToken={token}
host="https://stg-app.documenso.com"
documentId={documentId}
className="esignature-embed"
onDocumentUpdated={(data) => {
console.log("Document updated:", data.documentId);
}}
/>
) : (
<div>No token...</div>
)}
</div>
</Modal>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(EsignatureModalContainer);

View File

@@ -25,6 +25,7 @@ const Eula = ({ currentEula, currentUser, acceptEula }) => {
const handleScroll = useCallback(
(e) => {
if (!e.target) return;
const bottom = e.target.scrollHeight - 100 <= e.target.scrollTop + e.target.clientHeight;
if (bottom && !hasEverScrolledToBottom) {
setHasEverScrolledToBottom(true);
@@ -36,7 +37,9 @@ const Eula = ({ currentEula, currentUser, acceptEula }) => {
);
useEffect(() => {
handleScroll({ target: markdownCardRef.current });
if (markdownCardRef.current) {
handleScroll({ target: markdownCardRef.current });
}
}, [handleScroll]);
const handleChange = useCallback(() => {

View File

@@ -11,7 +11,7 @@ const mapDispatchToProps = () => ({
});
const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
if (!value) return null;
if (value === null || value === undefined || value === "") return null;
switch (type) {
case "employee": {
const emp = bodyshop.employees.find((e) => e.id === value);

View File

@@ -33,7 +33,7 @@ import JobLinesBillRefernece from "../job-lines-bill-reference/job-lines-bill-re
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import _ from "lodash";
import { FaTasks } from "react-icons/fa";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { selectAuthLevel, selectBodyshop } from "../../redux/user/user.selectors";
import dayjs from "../../utils/day";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
@@ -49,6 +49,7 @@ import JobLinesPartPriceChange from "./job-lines-part-price-change.component";
import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component.jsx";
const UPDATE_JOB_LINES_LOCATION_BULK = gql`
mutation UPDATE_JOB_LINES_LOCATION_BULK($ids: [uuid!]!, $location: String!) {
@@ -66,7 +67,8 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
jobRO: selectJobReadOnly,
technician: selectTechnician,
isPartsEntry: selectIsPartsEntry
isPartsEntry: selectIsPartsEntry,
authLevel: selectAuthLevel
});
const mapDispatchToProps = (dispatch) => ({
@@ -94,7 +96,8 @@ export function JobLinesComponent({
setTaskUpsertContext,
billsQuery,
handlePartsOrderOnRowClick,
isPartsEntry
isPartsEntry,
authLevel
}) {
const [deleteJobLine] = useMutation(DELETE_JOB_LINE_BY_PK);
const [bulkUpdateLocations] = useMutation(UPDATE_JOB_LINES_LOCATION_BULK);
@@ -386,18 +389,20 @@ export function JobLinesComponent({
key: "actions",
render: (text, record) => (
<Space>
{(record.manual_line || jobIsPrivate) && !technician && (
<Button
disabled={jobRO}
onClick={() => {
setJobLineEditContext({
actions: { refetch: refetch, submit: form && form.submit },
context: { ...record, jobid: job.id }
});
}}
icon={<EditFilled />}
/>
)}
{(record.manual_line || jobIsPrivate) &&
!technician &&
HasRbacAccess({ bodyshop, authLevel, action: "jobs:manual-line" }) && (
<Button
disabled={jobRO}
onClick={() => {
setJobLineEditContext({
actions: { refetch: refetch, submit: form && form.submit },
context: { ...record, jobid: job.id }
});
}}
icon={<EditFilled />}
/>
)}
<Button
title={t("tasks.buttons.create")}
onClick={() => {
@@ -410,29 +415,30 @@ export function JobLinesComponent({
}}
icon={<FaTasks />}
/>
{(record.manual_line || jobIsPrivate) && !technician && (
<Button
disabled={jobRO}
onClick={async () => {
await deleteJobLine({
variables: { joblineId: record.id },
update(cache) {
cache.modify({
fields: {
joblines(existingJobLines, { readField }) {
return existingJobLines.filter((jlRef) => record.id !== readField("id", jlRef));
{(record.manual_line || jobIsPrivate) &&
!technician &&
HasRbacAccess({ bodyshop, authLevel, action: "jobs:manual-line" }) && (
<Button
disabled={jobRO}
onClick={async () => {
await deleteJobLine({
variables: { joblineId: record.id },
update(cache) {
cache.modify({
fields: {
joblines(existingJobLines, { readField }) {
return existingJobLines.filter((jlRef) => record.id !== readField("id", jlRef));
}
}
}
});
}
});
await axios.post("/job/totalsssu", { id: job.id });
if (refetch) refetch();
}}
icon={<DeleteFilled />}
/>
)}
});
}
});
await axios.post("/job/totalsssu", { id: job.id });
if (refetch) refetch();
}}
icon={<DeleteFilled />}
/>
)}
</Space>
)
}
@@ -657,7 +663,7 @@ export function JobLinesComponent({
<Button id="repair-data-mark-button">{t("jobs.actions.mark")}</Button>
</Dropdown>
{!isPartsEntry && (
{!isPartsEntry && HasRbacAccess({ bodyshop, authLevel, action: "jobs:manual-line" }) && (
<Button
disabled={jobRO || technician}
onClick={() => {

View File

@@ -144,18 +144,11 @@ export default function JobTotalsTableLabor({ job }) {
{t("jobs.labels.mapa")}
{InstanceRenderManager({
imex:
job.materials?.mapa &&
job.materials.mapa.cal_maxdlr &&
job.materials.mapa.cal_maxdlr > 0 &&
t("jobs.labels.threshhold", {
amount: job.materials.mapa.cal_maxdlr
}),
(job.materials?.mapa ?? job.materials?.MAPA)?.cal_maxdlr > 0 &&
t("jobs.labels.threshhold", { amount: (job.materials.mapa ?? job.materials.MAPA).cal_maxdlr }),
rome:
job.materials?.MAPA &&
job.materials.MAPA.cal_maxdlr !== undefined &&
t("jobs.labels.threshhold", {
amount: job.materials.MAPA.cal_maxdlr
})
job.materials?.MAPA?.cal_maxdlr !== undefined &&
t("jobs.labels.threshhold", { amount: job.materials.MAPA.cal_maxdlr })
})}
</Space>
</ResponsiveTable.Summary.Cell>
@@ -190,18 +183,11 @@ export default function JobTotalsTableLabor({ job }) {
{t("jobs.labels.mash")}
{InstanceRenderManager({
imex:
job.materials?.mash &&
job.materials.mash.cal_maxdlr &&
job.materials.mash.cal_maxdlr > 0 &&
t("jobs.labels.threshhold", {
amount: job.materials.mash.cal_maxdlr
}),
(job.materials?.mash ?? job.materials?.MASH)?.cal_maxdlr > 0 &&
t("jobs.labels.threshhold", { amount: (job.materials.mash ?? job.materials.MASH).cal_maxdlr }),
rome:
job.materials?.MASH &&
job.materials.MASH.cal_maxdlr !== undefined &&
t("jobs.labels.threshhold", {
amount: job.materials.MASH.cal_maxdlr
})
job.materials?.MASH?.cal_maxdlr !== undefined &&
t("jobs.labels.threshhold", { amount: job.materials.MASH.cal_maxdlr })
})}
</Space>
</ResponsiveTable.Summary.Cell>

View File

@@ -69,7 +69,9 @@ export function JobsAdminClass({ bodyshop, job }) {
</Form>
<Popconfirm title={t("jobs.labels.changeclass")} onConfirm={() => form.submit()}>
<Button loading={loading}>{t("general.actions.save")}</Button>
<Button loading={loading} type="primary">
{t("general.actions.save")}
</Button>
</Popconfirm>
</div>
);

View File

@@ -157,7 +157,7 @@ export function JobsAdminDatesChange({ insertAuditTrail, job }) {
</LayoutFormRow>
</Form>
<Button loading={loading} onClick={() => form.submit()}>
<Button loading={loading} type="primary" onClick={() => form.submit()}>
{t("general.actions.save")}
</Button>
</div>

View File

@@ -54,7 +54,7 @@ export default function JobAdminOwnerReassociate({ job }) {
</Form.Item>
</Form>
<div>{t("jobs.labels.associationwarning")}</div>
<Button loading={loading} onClick={() => form.submit()}>
<Button loading={loading} type="primary" onClick={() => form.submit()}>
{t("general.actions.save")}
</Button>
</div>

View File

@@ -54,7 +54,7 @@ export default function JobAdminOwnerReassociate({ job }) {
</Form.Item>
</Form>
<div>{t("jobs.labels.associationwarning")}</div>
<Button loading={loading} onClick={() => form.submit()}>
<Button loading={loading} type="primary" onClick={() => form.submit()}>
{t("general.actions.save")}
</Button>
</div>

View File

@@ -138,7 +138,7 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
option?.value?.toLowerCase().indexOf(input?.toLowerCase()) >= 0
}}
disabled={jobRO}
options={bodyshop.md_responsibility_centers.profits.map((p) => ({
@@ -166,7 +166,7 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
option?.value?.toLowerCase().indexOf(input?.toLowerCase()) >= 0
}}
disabled={jobRO}
options={bodyshop.md_responsibility_centers.profits.map((p) => ({

View File

@@ -21,6 +21,8 @@ const mapStateToProps = createStructuredSelector({
technician: selectTechnician
});
const getRequestErrorMessage = (error) => error?.response?.data?.error || error?.message || "";
export function PayrollLaborAllocationsTable({
jobId,
joblines,
@@ -43,16 +45,23 @@ export function PayrollLaborAllocationsTable({
});
const notification = useNotification();
useEffect(() => {
async function CalculateTotals() {
const loadTotals = async () => {
try {
const { data } = await axios.post("/payroll/calculatelabor", {
jobid: jobId
});
setTotals(data);
} catch (error) {
setTotals([]);
notification.error({
title: getRequestErrorMessage(error)
});
}
};
useEffect(() => {
if (!!joblines && !!timetickets && !!bodyshop) {
CalculateTotals();
loadTotals();
}
if (!jobId) setTotals([]);
}, [joblines, timetickets, bodyshop, adjustments, jobId]);
@@ -210,28 +219,36 @@ export function PayrollLaborAllocationsTable({
<Button
disabled={!hasTimeTicketAccess}
onClick={async () => {
const response = await axios.post("/payroll/payall", {
jobid: jobId
});
try {
const response = await axios.post("/payroll/payall", {
jobid: jobId
});
if (response.status === 200) {
if (response.data.success !== false) {
notification.success({
title: t("timetickets.successes.payall")
});
if (response.status === 200) {
if (response.data.success !== false) {
notification.success({
title: t("timetickets.successes.payall")
});
} else {
notification.error({
title: t("timetickets.errors.payall", {
error: response.data.error
})
});
}
if (refetch) refetch();
} else {
notification.error({
title: t("timetickets.errors.payall", {
error: response.data.error
error: JSON.stringify("")
})
});
}
if (refetch) refetch();
} else {
} catch (error) {
notification.error({
title: t("timetickets.errors.payall", {
error: JSON.stringify("")
error: getRequestErrorMessage(error)
})
});
}
@@ -241,10 +258,7 @@ export function PayrollLaborAllocationsTable({
</Button>
<Button
onClick={async () => {
const { data } = await axios.post("/payroll/calculatelabor", {
jobid: jobId
});
setTotals(data);
await loadTotals();
refetch();
}}
icon={<SyncOutlined />}

View File

@@ -70,6 +70,12 @@ export function PartsOrderListTableComponent({
const [deletePartsOrder] = useMutation(DELETE_PARTS_ORDER);
const parts_orders = billsQuery.data ? billsQuery.data.parts_orders : [];
const enrichedPartsOrders = parts_orders.map((order) => ({
...order,
invoice_number: order.bill?.invoice_number
}));
const { refetch } = billsQuery;
const recordActions = (record, showView = false) => (
@@ -222,7 +228,12 @@ export function PartsOrderListTableComponent({
dataIndex: "order_number",
key: "order_number",
sorter: (a, b) => alphaSort(a.invoice_number, b.invoice_number),
sortOrder: state.sortedInfo.columnKey === "invoice_number" && state.sortedInfo.order
sortOrder: state.sortedInfo.columnKey === "invoice_number" && state.sortedInfo.order,
render: (text, record) => (
<span>
{record.order_number} {record.invoice_number && `(${record.invoice_number})`}
</span>
)
},
{
title: t("parts_orders.fields.order_date"),
@@ -272,10 +283,10 @@ export function PartsOrderListTableComponent({
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
};
const filteredPartsOrders = parts_orders
const filteredPartsOrders = enrichedPartsOrders
? searchText === ""
? parts_orders
: parts_orders.filter(
? enrichedPartsOrders
: enrichedPartsOrders.filter(
(b) =>
(b.order_number || "").toString().toLowerCase().includes(searchText.toLowerCase()) ||
(b.vendor.name || "").toLowerCase().includes(searchText.toLowerCase())

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,8 @@ 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";
const mapStateToProps = createStructuredSelector({
printCenterModal: selectPrintCenter,
@@ -17,9 +19,25 @@ 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();
@@ -39,6 +57,30 @@ export function PrintCenterItemComponent({ printCenterModal, item, id, bodyshop,
setLoading(false);
};
const esignatureGenerate = async () => {
setLoading(true);
try {
const {
data: { token, documentId, evnelopeId }
} = await axios.post("/esign/new", {
name: item.key,
jobid: id,
context,
bodyshop,
templateObject: {
name: item.key,
variables: { id: id }
}
});
setEsignatureContext({ context: { token, documentId, evnelopeId, jobid: id } });
} catch (error) {
console.log(error);
} finally {
setLoading(false);
}
};
if (
disabled ||
(item.featureNameRestricted && !HasFeatureAccess({ featureName: item.featureNameRestricted, bodyshop }))
@@ -54,6 +96,7 @@ export function PrintCenterItemComponent({ printCenterModal, item, id, bodyshop,
<li>
<Space wrap>
{item.title}
<SignatureFilled onClick={esignatureGenerate} />
<PrinterOutlined onClick={renderToNewWindow} />
{!technician ? (
<MailOutlined

View File

@@ -1,7 +1,7 @@
import Icon from "@ant-design/icons";
import { useMutation } from "@apollo/client/react";
import { Button, Input, Popover, Tooltip } from "antd";
import { useState } from "react";
import { useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { FaRegStickyNote } from "react-icons/fa";
import { UPDATE_JOB } from "../../graphql/jobs.queries";
@@ -9,10 +9,10 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
export default function ProductionListColumnComment({ record, usePortal = false }) {
const { t } = useTranslation();
const [note, setNote] = useState(record.comment || "");
const [open, setOpen] = useState(false);
const textAreaRef = useRef(null);
const rafIdRef = useRef(null);
const [updateAlert] = useMutation(UPDATE_JOB);
@@ -38,23 +38,35 @@ export default function ProductionListColumnComment({ record, usePortal = false
};
const handleOpenChange = (flag) => {
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
setOpen(flag);
if (flag) setNote(record.comment || "");
if (flag) {
setNote(record.comment || "");
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null;
if (textAreaRef.current?.focus) {
try {
textAreaRef.current.focus({ preventScroll: true });
} catch {
textAreaRef.current.focus();
}
}
});
}
};
const content = (
<div
style={{ width: "30em" }}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<div style={{ width: "30em" }} onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>
<Input.TextArea
id={`job-comment-${record.id}`}
name="comment"
rows={5}
value={note}
onChange={handleChange}
autoFocus
ref={textAreaRef}
allowClear
style={{ marginBottom: "1em" }}
/>
@@ -67,13 +79,13 @@ export default function ProductionListColumnComment({ record, usePortal = false
);
return (
<Popover
onOpenChange={handleOpenChange}
open={open}
content={content}
trigger="click"
<Popover
onOpenChange={handleOpenChange}
open={open}
content={content}
trigger="click"
destroyOnHidden
styles={{ body: { padding: '12px' } }}
styles={{ body: { padding: "12px" } }}
{...(usePortal ? { getPopupContainer: (trigger) => trigger.parentElement || document.body } : {})}
>
<div

View File

@@ -1,7 +1,7 @@
import Icon from "@ant-design/icons";
import { useMutation } from "@apollo/client/react";
import { Button, Input, Popover, Space } from "antd";
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaRegStickyNote } from "react-icons/fa";
import { logImEXEvent } from "../../firebase/firebase.utils";
@@ -20,6 +20,8 @@ function ProductionListColumnProductionNote({ record, setNoteUpsertContext, useP
const { t } = useTranslation();
const [note, setNote] = useState(record.production_vars?.note || "");
const [open, setOpen] = useState(false);
const textAreaRef = useRef(null);
const rafIdRef = useRef(null);
const [updateAlert] = useMutation(UPDATE_JOB);
@@ -52,25 +54,37 @@ function ProductionListColumnProductionNote({ record, setNoteUpsertContext, useP
const handleOpenChange = useCallback(
(flag) => {
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
setOpen(flag);
if (flag) setNote(record.production_vars?.note || "");
if (flag) {
setNote(record.production_vars?.note || "");
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null;
if (textAreaRef.current?.focus) {
try {
textAreaRef.current.focus({ preventScroll: true });
} catch {
textAreaRef.current.focus();
}
}
});
}
},
[record]
);
const content = (
<div
style={{ width: "30em" }}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<div style={{ width: "30em" }} onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>
<Input.TextArea
id={`job-production-note-${record.id}`}
name="production_note"
rows={5}
value={note}
onChange={handleChange}
autoFocus
ref={textAreaRef}
allowClear
style={{ marginBottom: "1em" }}
/>
@@ -96,13 +110,13 @@ function ProductionListColumnProductionNote({ record, setNoteUpsertContext, useP
);
return (
<Popover
onOpenChange={handleOpenChange}
open={open}
content={content}
trigger="click"
<Popover
onOpenChange={handleOpenChange}
open={open}
content={content}
trigger="click"
destroyOnHidden
styles={{ body: { padding: '12px' } }}
styles={{ body: { padding: "12px" } }}
{...(usePortal ? { getPopupContainer: (trigger) => trigger.parentElement || document.body } : {})}
>
<div

View File

@@ -26,6 +26,7 @@ const ret = {
"jobs:partsqueue": 4,
"jobs:checklist-view": 2,
"jobs:list-ready": 1,
"jobs:manual-line": 1,
"jobs:void": 5,
"bills:enter": 2,

View File

@@ -0,0 +1,99 @@
import { Grid, Table } from "antd";
import { useMemo } from "react";
import "./responsive-table.styles.scss";
function ResponsiveTable({ className, columns, mobileColumnKeys, scroll, tableLayout, ...rest }) {
const screens = Grid.useBreakpoint();
const isPhone = !screens.md;
const isCompactViewport = !screens.lg;
const prefersHorizontalScroll = isPhone || isCompactViewport;
const isResponsiveFilteringEnabled = ["1", "true", "yes", "on"].includes(
String(import.meta.env.VITE_APP_ENABLE_RESPONSIVE_TABLE_FILTERING || "")
.trim()
.toLowerCase()
);
const resolvedColumns = useMemo(() => {
if (
!isResponsiveFilteringEnabled ||
!Array.isArray(columns) ||
!isPhone ||
!Array.isArray(mobileColumnKeys) ||
mobileColumnKeys.length === 0
) {
return columns;
}
const visibleColumnKeys = new Set(mobileColumnKeys);
const filteredColumns = columns.filter((column) => {
const key = column?.key ?? column?.dataIndex;
// Keep columns with no stable key to avoid accidental loss.
if (key == null) return true;
if (Array.isArray(key)) {
return key.some((part) => visibleColumnKeys.has(part));
}
return visibleColumnKeys.has(key);
});
return filteredColumns.length > 0 ? filteredColumns : columns;
}, [columns, isPhone, isResponsiveFilteringEnabled, mobileColumnKeys]);
const resolvedScroll = useMemo(() => {
if (prefersHorizontalScroll) {
if (scroll == null) {
return { x: "max-content" };
}
if (typeof scroll !== "object" || Array.isArray(scroll)) {
return scroll;
}
const { x, ...baseScroll } = scroll;
return { ...baseScroll, x: x ?? "max-content" };
}
if (scroll == null) {
// Explicitly override ConfigProvider table.scroll desktop defaults.
return {};
}
if (typeof scroll !== "object" || Array.isArray(scroll)) {
return scroll;
}
const { x, ...desktopScroll } = scroll;
// On desktop we prefer fitting columns with ellipsis over forced horizontal scroll.
if (x == null) {
return desktopScroll;
}
return desktopScroll;
}, [prefersHorizontalScroll, scroll]);
const resolvedTableLayout = tableLayout ?? (prefersHorizontalScroll ? "auto" : "fixed");
const responsiveClassName = prefersHorizontalScroll ? undefined : "responsive-table-fit";
const resolvedClassName = [responsiveClassName, className].filter(Boolean).join(" ");
return (
<Table
className={resolvedClassName}
columns={resolvedColumns}
scroll={resolvedScroll}
tableLayout={resolvedTableLayout}
{...rest}
/>
);
}
ResponsiveTable.Summary = Table.Summary;
ResponsiveTable.Column = Table.Column;
ResponsiveTable.ColumnGroup = Table.ColumnGroup;
ResponsiveTable.SELECTION_COLUMN = Table.SELECTION_COLUMN;
ResponsiveTable.EXPAND_COLUMN = Table.EXPAND_COLUMN;
export default ResponsiveTable;

View File

@@ -1,93 +1,7 @@
import { Grid, Table } from "antd";
import { useMemo } from "react";
import "./responsive-table.styles.scss";
import { Table } from "antd";
function ResponsiveTable({ className, columns, mobileColumnKeys, scroll, tableLayout, ...rest }) {
const screens = Grid.useBreakpoint();
const isPhone = !screens.md;
const isCompactViewport = !screens.lg;
const prefersHorizontalScroll = isPhone || isCompactViewport;
const isResponsiveFilteringEnabled = ["1", "true", "yes", "on"].includes(
String(import.meta.env.VITE_APP_ENABLE_RESPONSIVE_TABLE_FILTERING || "")
.trim()
.toLowerCase()
);
const resolvedColumns = useMemo(() => {
if (
!isResponsiveFilteringEnabled ||
!Array.isArray(columns) ||
!isPhone ||
!Array.isArray(mobileColumnKeys) ||
mobileColumnKeys.length === 0
) {
return columns;
}
const visibleColumnKeys = new Set(mobileColumnKeys);
const filteredColumns = columns.filter((column) => {
const key = column?.key ?? column?.dataIndex;
// Keep columns with no stable key to avoid accidental loss.
if (key == null) return true;
if (Array.isArray(key)) {
return key.some((part) => visibleColumnKeys.has(part));
}
return visibleColumnKeys.has(key);
});
return filteredColumns.length > 0 ? filteredColumns : columns;
}, [columns, isPhone, isResponsiveFilteringEnabled, mobileColumnKeys]);
const resolvedScroll = useMemo(() => {
if (prefersHorizontalScroll) {
if (scroll == null) {
return { x: "max-content" };
}
if (typeof scroll !== "object" || Array.isArray(scroll)) {
return scroll;
}
const { x, ...baseScroll } = scroll;
return { ...baseScroll, x: x ?? "max-content" };
}
if (scroll == null) {
// Explicitly override ConfigProvider table.scroll desktop defaults.
return {};
}
if (typeof scroll !== "object" || Array.isArray(scroll)) {
return scroll;
}
const { x, ...desktopScroll } = scroll;
// On desktop we prefer fitting columns with ellipsis over forced horizontal scroll.
if (x == null) {
return desktopScroll;
}
return desktopScroll;
}, [prefersHorizontalScroll, scroll]);
const resolvedTableLayout = tableLayout ?? (prefersHorizontalScroll ? "auto" : "fixed");
const responsiveClassName = prefersHorizontalScroll ? undefined : "responsive-table-fit";
const resolvedClassName = [responsiveClassName, className].filter(Boolean).join(" ");
return (
<Table
className={resolvedClassName}
columns={resolvedColumns}
scroll={resolvedScroll}
tableLayout={resolvedTableLayout}
{...rest}
/>
);
function ResponsiveTable(props) {
return <Table {...props} />;
}
ResponsiveTable.Summary = Table.Summary;

View File

@@ -435,6 +435,19 @@ export function ShopInfoRbacComponent({ bodyshop }) {
>
<InputNumber />
</Form.Item>,
<Form.Item
key="jobs:manual-line"
label={t("bodyshop.fields.rbac.jobs.manual-line")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_rbac", "jobs:manual-line"]}
>
<InputNumber />
</Form.Item>,
<Form.Item
key="jobs:partsqueue"
label={t("bodyshop.fields.rbac.jobs.partsqueue")}

View File

@@ -16,6 +16,43 @@ const mapDispatchToProps = () => ({
});
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoTaskPresets);
const normalizePercent = (value) => Math.round((Number(value || 0) + Number.EPSILON) * 10000) / 10000;
const getTaskPresetAllocationErrors = (presets = [], t) => {
const totalsByLaborType = {};
presets.forEach((preset) => {
const percent = normalizePercent(preset?.percent);
if (!percent) {
return;
}
const laborTypes = Array.isArray(preset?.hourstype) ? preset.hourstype : [];
laborTypes.forEach((laborType) => {
if (!laborType) {
return;
}
totalsByLaborType[laborType] = normalizePercent((totalsByLaborType[laborType] || 0) + percent);
});
});
return Object.entries(totalsByLaborType)
.filter(([, total]) => total > 100)
.map(([laborType, total]) => {
const translatedLaborType = t(`joblines.fields.lbr_types.${laborType}`);
const laborTypeLabel =
translatedLaborType === `joblines.fields.lbr_types.${laborType}` ? laborType : translatedLaborType;
return t("bodyshop.errors.task_preset_allocation_exceeded", {
laborType: laborTypeLabel,
total
});
});
};
export function ShopInfoTaskPresets({ bodyshop }) {
const { t } = useTranslation();
@@ -39,8 +76,21 @@ export function ShopInfoTaskPresets({ bodyshop }) {
</LayoutFormRow>
<LayoutFormRow header={t("bodyshop.labels.md_tasks_presets")}>
<Form.List name={["md_tasks_presets", "presets"]}>
{(fields, { add, remove, move }) => {
<Form.List
name={["md_tasks_presets", "presets"]}
rules={[
{
validator: async (_, presets) => {
const allocationErrors = getTaskPresetAllocationErrors(presets, t);
if (allocationErrors.length > 0) {
throw new Error(allocationErrors.join(" "));
}
}
}
]}
>
{(fields, { add, remove, move }, { errors }) => {
return (
<div>
{fields.map((field, index) => (
@@ -189,6 +239,7 @@ export function ShopInfoTaskPresets({ bodyshop }) {
</LayoutFormRow>
</Form.Item>
))}
<Form.ErrorList errors={errors} />
<Form.Item>
<Button
type="dashed"

View File

@@ -1,6 +1,6 @@
import { DeleteFilled } from "@ant-design/icons";
import { useMutation, useQuery } from "@apollo/client/react";
import { Button, Card, Form, Input, InputNumber, Space, Switch } from "antd";
import { Button, Card, Form, Input, InputNumber, Select, Space, Switch, Typography } from "antd";
import querystring from "query-string";
import { useEffect } from "react";
@@ -26,10 +26,32 @@ import { useNotification } from "../../contexts/Notifications/notificationContex
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
const mapDispatchToProps = () => ({});
const LABOR_TYPES = ["LAA", "LAB", "LAD", "LAE", "LAF", "LAG", "LAM", "LAR", "LAS", "LAU", "LA1", "LA2", "LA3", "LA4"];
const PAYOUT_METHOD_OPTIONS = [
{ labelKey: "employee_teams.options.hourly", value: "hourly" },
{ labelKey: "employee_teams.options.commission_percentage", value: "commission" }
];
const normalizeTeamMember = (teamMember = {}) => ({
...teamMember,
payout_method: teamMember.payout_method || "hourly",
labor_rates: teamMember.labor_rates || {},
commission_rates: teamMember.commission_rates || {}
});
const normalizeEmployeeTeam = (employeeTeam = {}) => ({
...employeeTeam,
employee_team_members: (employeeTeam.employee_team_members || []).map(normalizeTeamMember)
});
const getSplitTotal = (teamMembers = []) =>
teamMembers.reduce((sum, member) => sum + Number(member?.percentage || 0), 0);
const hasExactSplitTotal = (teamMembers = []) => Math.abs(getSplitTotal(teamMembers) - 100) < 0.00001;
export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
const { t } = useTranslation();
const [form] = Form.useForm();
@@ -45,38 +67,73 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
});
useEffect(() => {
if (data?.employee_teams_by_pk) form.setFieldsValue(data.employee_teams_by_pk);
else {
if (data?.employee_teams_by_pk) {
form.setFieldsValue(normalizeEmployeeTeam(data.employee_teams_by_pk));
} else {
form.resetFields();
}
}, [form, data, search.employeeTeamId]);
const [updateEmployeeTeam] = useMutation(UPDATE_EMPLOYEE_TEAM);
const [insertEmployeeTeam] = useMutation(INSERT_EMPLOYEE_TEAM);
const payoutMethodOptions = PAYOUT_METHOD_OPTIONS.map(({ labelKey, value }) => ({
label: t(labelKey),
value
}));
const handleFinish = async ({ employee_team_members = [], ...values }) => {
const normalizedTeamMembers = employee_team_members.map((teamMember) => {
const nextTeamMember = normalizeTeamMember({ ...teamMember });
delete nextTeamMember.__typename;
return nextTeamMember;
});
if (normalizedTeamMembers.length === 0) {
notification.error({
title: t("employee_teams.errors.minimum_one_member")
});
return;
}
const employeeIds = normalizedTeamMembers.map((teamMember) => teamMember.employeeid).filter(Boolean);
const duplicateEmployeeIds = employeeIds.filter((employeeId, index) => employeeIds.indexOf(employeeId) !== index);
if (duplicateEmployeeIds.length > 0) {
notification.error({
title: t("employee_teams.errors.duplicate_member")
});
return;
}
if (!hasExactSplitTotal(normalizedTeamMembers)) {
notification.error({
title: t("employee_teams.errors.allocation_total_exact")
});
return;
}
const handleFinish = async ({ employee_team_members, ...values }) => {
if (search.employeeTeamId && search.employeeTeamId !== "new") {
//Update a record.
logImEXEvent("shop_employee_update");
const result = await updateEmployeeTeam({
variables: {
employeeTeamId: search.employeeTeamId,
employeeTeam: values,
teamMemberUpdates: employee_team_members
.filter((e) => e.id)
.map((e) => {
delete e.__typename;
return { where: { id: { _eq: e.id } }, _set: e };
}),
teamMemberInserts: employee_team_members
.filter((e) => e.id === null || e.id === undefined)
.map((e) => ({ ...e, teamid: search.employeeTeamId })),
teamMemberDeletes: data.employee_teams_by_pk.employee_team_members.filter(
(e) => !employee_team_members.find((etm) => etm.id === e.id)
)
teamMemberUpdates: normalizedTeamMembers
.filter((teamMember) => teamMember.id)
.map((teamMember) => ({
where: { id: { _eq: teamMember.id } },
_set: teamMember
})),
teamMemberInserts: normalizedTeamMembers
.filter((teamMember) => teamMember.id === null || teamMember.id === undefined)
.map((teamMember) => ({ ...teamMember, teamid: search.employeeTeamId })),
teamMemberDeletes: data.employee_teams_by_pk.employee_team_members
.filter((teamMember) => !normalizedTeamMembers.find((currentTeamMember) => currentTeamMember.id === teamMember.id))
.map((teamMember) => teamMember.id)
}
});
if (!result.errors) {
notification.success({
title: t("employees.successes.save")
@@ -89,20 +146,19 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
});
}
} else {
//New record, insert it.
logImEXEvent("shop_employee_insert");
insertEmployeeTeam({
variables: {
employeeTeam: {
...values,
employee_team_members: { data: employee_team_members },
employee_team_members: { data: normalizedTeamMembers },
bodyshopid: bodyshop.id
}
},
refetchQueries: ["QUERY_TEAMS"]
}).then((r) => {
search.employeeTeamId = r.data.insert_employee_teams_one.id;
}).then((response) => {
search.employeeTeamId = response.data.insert_employee_teams_one.id;
history({ search: querystring.stringify(search) });
notification.success({
title: t("employees.successes.save")
@@ -130,7 +186,6 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
@@ -145,7 +200,6 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
@@ -169,207 +223,61 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<EmployeeSearchSelectComponent options={bodyshop.employees} />
</Form.Item>
<Form.Item
label={t("employee_teams.fields.percentage")}
label={t("employee_teams.fields.allocation_percentage")}
key={`${index}`}
name={[field.name, "percentage"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAA")}
key={`${index}`}
name={[field.name, "labor_rates", "LAA"]}
label={t("employee_teams.fields.payout_method")}
key={`${index}-payout-method`}
name={[field.name, "payout_method"]}
initialValue="hourly"
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAB")}
key={`${index}`}
name={[field.name, "labor_rates", "LAB"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAD")}
key={`${index}`}
name={[field.name, "labor_rates", "LAD"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAE")}
key={`${index}`}
name={[field.name, "labor_rates", "LAE"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
<Select options={payoutMethodOptions} />
</Form.Item>
<Form.Item noStyle dependencies={[["employee_team_members", field.name, "payout_method"]]}>
{() => {
const payoutMethod =
form.getFieldValue(["employee_team_members", field.name, "payout_method"]) || "hourly";
const fieldName = payoutMethod === "commission" ? "commission_rates" : "labor_rates";
<Form.Item
label={t("joblines.fields.lbr_types.LAF")}
key={`${index}`}
name={[field.name, "labor_rates", "LAF"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAG")}
key={`${index}`}
name={[field.name, "labor_rates", "LAG"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAM")}
key={`${index}`}
name={[field.name, "labor_rates", "LAM"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAR")}
key={`${index}`}
name={[field.name, "labor_rates", "LAR"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAS")}
key={`${index}`}
name={[field.name, "labor_rates", "LAS"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAU")}
key={`${index}`}
name={[field.name, "labor_rates", "LAU"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA1")}
key={`${index}`}
name={[field.name, "labor_rates", "LA1"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA2")}
key={`${index}`}
name={[field.name, "labor_rates", "LA2"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA3")}
key={`${index}`}
name={[field.name, "labor_rates", "LA3"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA4")}
key={`${index}`}
name={[field.name, "labor_rates", "LA4"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
return LABOR_TYPES.map((laborType) => (
<Form.Item
label={payoutMethod === "commission" ? `${t(`joblines.fields.lbr_types.${laborType}`)} %` : t(`joblines.fields.lbr_types.${laborType}`)}
key={`${index}-${fieldName}-${laborType}`}
name={[field.name, fieldName, laborType]}
rules={[
{
required: true
}
]}
>
{payoutMethod === "commission" ? (
<InputNumber min={0} max={100} precision={2} />
) : (
<CurrencyInput />
)}
</Form.Item>
));
}}
</Form.Item>
<Space align="center">
<DeleteFilled
@@ -386,13 +294,32 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
<Button
type="dashed"
onClick={() => {
add();
add({
percentage: 0,
payout_method: "hourly",
labor_rates: {},
commission_rates: {}
});
}}
style={{ width: "100%" }}
>
{t("employee_teams.actions.newmember")}
</Button>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{() => {
const teamMembers = form.getFieldValue(["employee_team_members"]) || [];
const splitTotal = getSplitTotal(teamMembers);
return (
<Typography.Text type={hasExactSplitTotal(teamMembers) ? undefined : "danger"}>
{t("employee_teams.labels.allocation_total", {
total: splitTotal.toFixed(2)
})}
</Typography.Text>
);
}}
</Form.Item>
</div>
);
}}

View File

@@ -66,10 +66,9 @@ export function TechClockInContainer({ setTimeTicketContext, technician, bodysho
employeeid: technician.id,
date:
typeof bodyshop.timezone === "string"
? // TODO: Client Update - This may be broken
dayjs.tz(theTime, bodyshop.timezone).format("YYYY-MM-DD")
? dayjs(theTime).tz(bodyshop.timezone).format("YYYY-MM-DD")
: typeof bodyshop.timezone === "number"
? dayjs(theTime).format("YYYY-MM-DD").utcOffset(bodyshop.timezone)
? dayjs(theTime).utcOffset(bodyshop.timezone).format("YYYY-MM-DD")
: dayjs(theTime).format("YYYY-MM-DD"),
clockon: dayjs(theTime),
jobid: values.jobid,

View File

@@ -25,10 +25,7 @@ const mapDispatchToProps = (dispatch) => ({
});
export function TechLookupJobsDrawer({ bodyshop, setPrintCenterContext }) {
const breakpoints = Grid.useBreakpoint();
const selectedBreakpoint = Object.entries(breakpoints)
.filter(([, isOn]) => !!isOn)
.slice(-1)[0];
const screens = Grid.useBreakpoint();
const bpoints = {
xs: "100%",
@@ -36,10 +33,16 @@ export function TechLookupJobsDrawer({ bodyshop, setPrintCenterContext }) {
md: "100%",
lg: "100%",
xl: "90%",
xxl: "85%"
xxl: "90%"
};
const drawerPercentage = selectedBreakpoint ? bpoints[selectedBreakpoint[0]] : "100%";
let drawerPercentage = "100%";
if (screens.xxl) drawerPercentage = bpoints.xxl;
else if (screens.xl) drawerPercentage = bpoints.xl;
else if (screens.lg) drawerPercentage = bpoints.lg;
else if (screens.md) drawerPercentage = bpoints.md;
else if (screens.sm) drawerPercentage = bpoints.sm;
else if (screens.xs) drawerPercentage = bpoints.xs;
const location = useLocation();
const history = useNavigate();

View File

@@ -47,7 +47,7 @@ export function TimeTicketModalComponent({
} = useTreatmentsWithConfig({
attributes: {},
names: ["Enhanced_Payroll"],
splitKey: bodyshop.imexshopid
splitKey: bodyshop?.imexshopid
});
const [loadLineTicketData, { loading, data: lineTicketData, refetch }] = useLazyQuery(GET_LINE_TICKET_BY_PK, {
@@ -347,7 +347,7 @@ export function LaborAllocationContainer({
} = useTreatmentsWithConfig({
attributes: {},
names: ["Enhanced_Payroll"],
splitKey: bodyshop.imexshopid
splitKey: bodyshop?.imexshopid
});
if (loading) return <LoadingSkeleton />;

View File

@@ -35,7 +35,15 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
<JobSearchSelectComponent convertedOnly={true} notExported={true} />
</Form.Item>
<Space wrap>
<Form.Item name="task" label={t("timetickets.labels.task")}>
<Form.Item
name="task"
label={t("timetickets.labels.task")}
rules={[
{
required: true
}
]}
>
{loading ? (
<Spin />
) : (
@@ -93,6 +101,8 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
<th>{t("timetickets.fields.cost_center")}</th>
<th>{t("timetickets.fields.ciecacode")}</th>
<th>{t("timetickets.fields.productivehrs")}</th>
<th>{t("timetickets.fields.rate")}</th>
<th>{t("timetickets.fields.amount")}</th>
</tr>
</thead>
<tbody>
@@ -118,6 +128,16 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>
<Form.Item key={`${index}rate`} name={[field.name, "rate"]}>
<ReadOnlyFormItemComponent type="currency" />
</Form.Item>
</td>
<td>
<Form.Item key={`${index}payoutamount`} name={[field.name, "payoutamount"]}>
<ReadOnlyFormItemComponent type="currency" />
</Form.Item>
</td>
</tr>
))}
</tbody>

View File

@@ -90,7 +90,12 @@ export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicke
if (actions?.refetch) actions.refetch();
toggleModalVisible();
} else if (handleFinish === false) {
form.setFieldsValue({ timetickets: data.ticketsToInsert });
form.setFieldsValue({
timetickets: (data.ticketsToInsert || []).map((ticket) => ({
...ticket,
payoutamount: Number(ticket.productivehrs || 0) * Number(ticket.rate || 0)
}))
});
setUnassignedHours(data.unassignedHours);
} else {
notification.error({
@@ -101,7 +106,9 @@ export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicke
}
} catch (error) {
notification.error({
title: t("timetickets.errors.creating", { message: error.message })
title: t("timetickets.errors.creating", {
message: error.response?.data?.error || error.message
})
});
} finally {
setLoading(false);

View File

@@ -130,7 +130,15 @@ export function TtApprovalsListComponent({
key: "memo",
sorter: (a, b) => alphaSort(a.memo, b.memo),
sortOrder: state.sortedInfo.columnKey === "memo" && state.sortedInfo.order,
render: (text, record) => (record.clockon || record.clockoff ? t(record.memo) : record.memo)
render: (text, record) => (record.memo?.startsWith("timetickets.labels") ? t(record.memo) : record.memo)
},
{
title: t("timetickets.fields.task_name"),
dataIndex: "task_name",
key: "task_name",
sorter: (a, b) => alphaSort(a.task_name, b.task_name),
sortOrder: state.sortedInfo.columnKey === "task_name" && state.sortedInfo.order,
render: (text, record) => record.task_name || ""
},
{
title: t("timetickets.fields.clockon"),
@@ -140,12 +148,12 @@ export function TtApprovalsListComponent({
render: (text, record) => <DateTimeFormatter>{record.clockon}</DateTimeFormatter>
},
{
title: "Pay",
title: t("timetickets.fields.pay"),
dataIndex: "pay",
key: "pay",
render: (text, record) =>
Dinero({ amount: Math.round(record.rate * 100) })
.multiply(record.flat_rate ? record.productivehrs : record.actualhrs)
Dinero({ amount: Math.round((record.rate || 0) * 100) })
.multiply(record.flat_rate ? record.productivehrs || 0 : record.actualhrs || 0)
.toFormat("$0.00")
}
];
@@ -184,7 +192,7 @@ export function TtApprovalsListComponent({
<ResponsiveTable
loading={loading}
columns={columns}
mobileColumnKeys={["ro_number", "date", "employeeid", "cost_center"]}
mobileColumnKeys={["ro_number", "date", "employeeid", "cost_center", "task_name"]}
rowKey="id"
scroll={{
x: true

View File

@@ -18,7 +18,15 @@ const mapStateToProps = createStructuredSelector({
authLevel: selectAuthLevel
});
export function TtApproveButton({ bodyshop, currentUser, selectedTickets, disabled, authLevel }) {
export function TtApproveButton({
bodyshop,
currentUser,
selectedTickets,
disabled,
authLevel,
completedCallback,
refetch
}) {
const { t } = useTranslation();
const client = useApolloClient();
const notification = useNotification();
@@ -54,6 +62,12 @@ export function TtApproveButton({ bodyshop, currentUser, selectedTickets, disabl
})
});
} else {
if (typeof completedCallback === "function") {
completedCallback([]);
}
if (typeof refetch === "function") {
refetch();
}
notification.success({
title: t("timetickets.successes.created")
});
@@ -68,8 +82,6 @@ export function TtApproveButton({ bodyshop, currentUser, selectedTickets, disabl
setLoading(false);
}
// if (!!completedCallback) completedCallback([]);
// if (!!loadingCallback) loadingCallback(false);
};
return (

View File

@@ -91,6 +91,10 @@ export const QUERY_PARTS_BILLS_BY_JOBID = gql`
order_number
comments
user_email
bill {
id
invoice_number
}
}
parts_dispatch(where: { jobid: { _eq: $jobid } }) {
id

View File

@@ -152,6 +152,8 @@ export const QUERY_BODYSHOP = gql`
id
employeeid
labor_rates
payout_method
commission_rates
percentage
}
}
@@ -285,6 +287,8 @@ export const UPDATE_SHOP = gql`
id
employeeid
labor_rates
payout_method
commission_rates
percentage
}
}

View File

@@ -10,6 +10,8 @@ export const QUERY_TEAMS = gql`
id
employeeid
labor_rates
payout_method
commission_rates
percentage
}
}
@@ -29,6 +31,8 @@ export const UPDATE_EMPLOYEE_TEAM = gql`
employeeid
id
labor_rates
payout_method
commission_rates
percentage
}
}
@@ -40,6 +44,8 @@ export const UPDATE_EMPLOYEE_TEAM = gql`
employeeid
id
labor_rates
payout_method
commission_rates
percentage
}
}
@@ -52,6 +58,8 @@ export const UPDATE_EMPLOYEE_TEAM = gql`
employeeid
id
labor_rates
payout_method
commission_rates
percentage
}
}
@@ -69,6 +77,8 @@ export const INSERT_EMPLOYEE_TEAM = gql`
employeeid
id
labor_rates
payout_method
commission_rates
percentage
}
}
@@ -86,6 +96,8 @@ export const QUERY_EMPLOYEE_TEAM_BY_ID = gql`
employeeid
id
labor_rates
payout_method
commission_rates
percentage
}
}

View File

@@ -1375,6 +1375,9 @@ export const QUERY_JOB_FOR_DUPE = gql`
agt_ph2x
area_of_damage
cat_no
cieca_pfl
cieca_pfo
cieca_pft
cieca_stl
cieca_ttl
clm_addr1
@@ -1452,6 +1455,7 @@ export const QUERY_JOB_FOR_DUPE = gql`
labor_rate_desc
labor_rate_id
local_tax_rate
materials
other_amount_payable
owner_owing
ownerid

View File

@@ -260,6 +260,7 @@ export const INSERT_TIME_TICKET_AND_APPROVE = gql`
id
clockon
clockoff
created_by
employeeid
productivehrs
actualhrs
@@ -267,6 +268,9 @@ export const INSERT_TIME_TICKET_AND_APPROVE = gql`
date
memo
flat_rate
task_name
payout_context
ttapprovalqueueid
commited_by
committed_at
}

View File

@@ -23,7 +23,14 @@ export const QUERY_ALL_TT_APPROVALS_PAGINATED = gql`
ciecacode
cost_center
date
memo
flat_rate
clockon
clockoff
rate
created_by
task_name
payout_context
}
tt_approval_queue_aggregate {
aggregate {
@@ -42,9 +49,16 @@ export const INSERT_NEW_TT_APPROVALS = gql`
productivehrs
actualhrs
ciecacode
cost_center
date
memo
flat_rate
rate
clockon
clockoff
created_by
task_name
payout_context
}
}
}
@@ -65,6 +79,11 @@ export const QUERY_TT_APPROVALS_BY_IDS = gql`
ciecacode
bodyshopid
cost_center
clockon
clockoff
created_by
task_name
payout_context
}
}
`;

View File

@@ -11,7 +11,7 @@ import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { QUERY_JOB_EXPORT_DMS } from "../../graphql/jobs.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
@@ -29,7 +29,8 @@ import DmsAllocationsSummary from "../../components/dms-allocations-summary/dms-
import RrAllocationsSummary from "../../components/dms-allocations-summary/rr-dms-allocations-summary.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
});
const mapDispatchToProps = (dispatch) => ({
@@ -65,7 +66,41 @@ const DMS_SOCKET_EVENTS = {
}
};
export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, insertAuditTrail }) {
const stripRrXmlFromPayload = (input) => {
if (input == null || typeof input !== "object") return input;
let target = null;
try {
target = JSON.parse(JSON.stringify(input));
} catch {
// Fallback to in-place scrub if cloning fails.
target = input;
}
const scrub = (node) => {
if (node == null || typeof node !== "object") return;
if (Array.isArray(node)) {
node.forEach(scrub);
return;
}
delete node.requestXml;
delete node.responseXml;
if (node.xml && typeof node.xml === "object") {
delete node.xml.request;
delete node.xml.response;
if (Object.keys(node.xml).length === 0) delete node.xml;
}
Object.values(node).forEach(scrub);
};
scrub(target);
return target;
};
export function DmsContainer({ bodyshop, currentUser, setBreadcrumbs, setSelectedHeader, insertAuditTrail }) {
const {
treatments: { Fortellis }
} = useTreatmentsWithConfig({
@@ -79,6 +114,15 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
const [allocationsSummary, setAllocationsSummary] = useState(null);
const [reconnectNonce, setReconnectNonce] = useState(0);
const isDevEnv = import.meta.env.DEV;
const isProdEnv = import.meta.env.PROD;
const userEmail = (currentUser?.email || "").toLowerCase();
const devEmails = ["imex.dev", "rome.dev"];
const prodEmails = ["imex.prod", "rome.prod", "imex.test", "rome.test"];
const hasValidEmail = (emails) => emails.some((email) => userEmail.endsWith(email));
const canViewSensitiveRrXml = (isDevEnv && hasValidEmail(devEmails)) || (isProdEnv && hasValidEmail(prodEmails));
// Compute a single normalized mode and pick the proper socket
const mode = getDmsMode(bodyshop, Fortellis.treatment); // "rr" | "fortellis" | "cdk" | "pbs" | "none"
@@ -164,19 +208,21 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
const providerLabel = useMemo(
() =>
({
[DMS_MAP.reynolds]: "Reynolds",
[DMS_MAP.fortellis]: "Fortellis",
[DMS_MAP.cdk]: "CDK",
[DMS_MAP.pbs]: "PBS"
})[mode] || "DMS",
[mode]
[DMS_MAP.reynolds]: t("dms.labels.provider_reynolds"),
[DMS_MAP.fortellis]: t("dms.labels.provider_fortellis"),
[DMS_MAP.cdk]: t("dms.labels.provider_cdk"),
[DMS_MAP.pbs]: t("dms.labels.provider_pbs")
})[mode] || t("dms.labels.provider_dms"),
[mode, t]
);
const transportLabel = isWssMode(mode) ? "(WSS)" : "(WS)";
const transportLabel = isWssMode(mode) ? t("dms.labels.transport_wss") : t("dms.labels.transport_ws");
const bannerMessage = `Posting to ${providerLabel} | ${transportLabel} | ${
isConnected ? "Connected" : "Disconnected"
}`;
const bannerMessage = t("dms.labels.banner_message", {
provider: providerLabel,
transport: transportLabel,
status: isConnected ? t("dms.labels.banner_status_connected") : t("dms.labels.banner_status_disconnected")
});
const resetKey = useMemo(() => `${mode || "none"}-${jobId || "none"}`, [mode, jobId]);
const customerSelectorKey = useMemo(() => `${resetKey}-${reconnectNonce}`, [resetKey, reconnectNonce]);
@@ -239,6 +285,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
}, [jobId, mode, activeSocket]);
const handleExportFailed = (payload = {}) => {
const safePayload = canViewSensitiveRrXml ? payload : stripRrXmlFromPayload(payload);
const { title, friendlyMessage, error: errText, severity, errorCode, vendorStatusCode } = payload;
const msg =
@@ -246,7 +293,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
errText ||
t("dms.errors.exportfailedgeneric", "We couldn't complete the export. Please try again.");
const vendorTitle = title || (isRrMode ? "Reynolds" : "DMS");
const vendorTitle = title || (isRrMode ? t("dms.labels.provider_reynolds") : t("dms.labels.provider_dms"));
const isRrOpenRoLimit =
isRrMode &&
@@ -269,7 +316,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
timestamp: new Date(),
level: (sev || "error").toUpperCase(),
message: `${vendorTitle}: ${msg}`,
meta: { errorCode, vendorStatusCode, raw: payload, blockedByOpenRoLimit: !!isRrOpenRoLimit }
meta: { errorCode, vendorStatusCode, raw: safePayload, blockedByOpenRoLimit: !!isRrOpenRoLimit }
}
]);
};
@@ -321,7 +368,9 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
{
timestamp: new Date(),
level: "warn",
message: `Reconnected to ${isRrMode ? "RR" : mode === DMS_MAP.fortellis ? "Fortellis" : "DMS"} Export Service`
message: t("dms.labels.reconnected_export_service", {
provider: isRrMode ? t("dms.labels.provider_reynolds") : providerLabel
})
}
]);
};
@@ -340,11 +389,16 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
// Logs
const onLog = isRrMode
? (payload = {}) => {
const safePayload = canViewSensitiveRrXml ? payload : stripRrXmlFromPayload(payload);
const normalized = {
timestamp: payload.timestamp ? new Date(payload.timestamp) : payload.ts ? new Date(payload.ts) : new Date(),
level: (payload.level || "INFO").toUpperCase(),
message: payload.message || payload.msg || "",
meta: payload.meta ?? payload.ctx ?? payload.details ?? null
timestamp: safePayload.timestamp
? new Date(safePayload.timestamp)
: safePayload.ts
? new Date(safePayload.ts)
: new Date(),
level: (safePayload.level || "INFO").toUpperCase(),
message: safePayload.message || safePayload.msg || "",
meta: safePayload.meta ?? safePayload.ctx ?? safePayload.details ?? null
};
setLogs((prev) => [...prev, normalized]);
}
@@ -380,14 +434,12 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
{
timestamp: new Date(),
level: "INFO",
message:
"Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize."
message: t("dms.labels.rr_validation_message")
}
]);
notification.info({
title: "Reynolds RO created",
description:
"Complete validation in Reynolds, then click Finished/Close to finalize and mark this export complete.",
title: t("dms.labels.rr_validation_notice_title"),
description: t("dms.labels.rr_validation_notice_description"),
duration: 8
});
};
@@ -399,8 +451,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
{
timestamp: new Date(),
level: "INFO",
message:
"Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize.",
message: t("dms.labels.rr_validation_message"),
meta: { payload }
}
]);
@@ -428,7 +479,19 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
activeSocket.disconnect();
}
};
}, [mode, activeSocket, channels, logLevel, notification, t, insertAuditTrail, history]);
}, [
mode,
activeSocket,
channels,
logLevel,
notification,
t,
insertAuditTrail,
history,
isRrMode,
providerLabel,
canViewSensitiveRrXml
]);
// RR finalize callback (unchanged public behavior)
const handleRrValidationFinished = () => {
@@ -471,7 +534,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
<AlertComponent style={{ marginBottom: 10 }} title={bannerMessage} type="warning" showIcon closable />
<Row gutter={[16, 16]}>
<Col md={24} lg={10} className="dms-equal-height-col">
<Col xs={24} xxl={10} className="dms-equal-height-col dms-top-panel-col">
{!isRrMode ? (
<DmsAllocationsSummary
key={resetKey}
@@ -511,7 +574,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
)}
</Col>
<Col md={24} lg={14} className="dms-equal-height-col">
<Col xs={24} xxl={14} className="dms-equal-height-col dms-top-panel-col">
<DmsPostForm
key={resetKey}
socket={activeSocket}
@@ -550,15 +613,17 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
<Switch
checked={colorizeJson}
onChange={setColorizeJson}
checkedChildren="Color JSON"
unCheckedChildren="Plain JSON"
checkedChildren={t("dms.labels.color_json")}
unCheckedChildren={t("dms.labels.plain_json")}
/>
<Button onClick={toggleDetailsAll}>{detailsOpen ? "Collapse All" : "Expand All"}</Button>
<Button onClick={toggleDetailsAll}>
{detailsOpen ? t("dms.labels.collapse_all") : t("dms.labels.expand_all")}
</Button>
</>
)}
<Select
placeholder="Log Level"
placeholder={t("dms.labels.log_level")}
value={logLevel}
onChange={(value) => {
setLogLevel(value);
@@ -572,8 +637,8 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
{ key: "ERROR", value: "ERROR", label: "ERROR" }
]}
/>
<Button onClick={() => setLogs([])}>Clear Logs</Button>
<Button onClick={handleReconnectClick}>Reconnect</Button>
<Button onClick={() => setLogs([])}>{t("dms.labels.clear_logs")}</Button>
<Button onClick={handleReconnectClick}> {t("dms.labels.reconnect")}</Button>
</Space>
}
>
@@ -585,6 +650,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
detailsNonce={detailsNonce}
colorizeJson={isRrMode ? colorizeJson : false}
showDetails={isRrMode}
allowXmlPayload={canViewSensitiveRrXml}
/>
</Card>
</div>

View File

@@ -142,13 +142,13 @@ export function ExportLogsPageComponent() {
<div>
<ul>
{message.map((m, idx) => (
<li key={idx}>{m}</li>
<li key={idx}>{typeof m === "object" ? JSON.stringify(m) : m}</li>
))}
</ul>
</div>
);
} else {
return <div>{record.message}</div>;
return <div>{typeof message === "object" ? JSON.stringify(message) : message}</div>;
}
}
}

View File

@@ -10,14 +10,12 @@ import JobsCreateOwnerInfoContainer from "../../components/jobs-create-owner-inf
import JobsCreateVehicleInfoContainer from "../../components/jobs-create-vehicle-info/jobs-create-vehicle-info.container";
import JobCreateContext from "../../pages/jobs-create/jobs-create.context";
export default function JobsCreateComponent({ form }) {
export default function JobsCreateComponent({ form, isSubmitting }) {
const [pageIndex, setPageIndex] = useState(0);
const [errorMessage, setErrorMessage] = useState(null);
const [state] = useContext(JobCreateContext);
const { t } = useTranslation();
const steps = [
{
title: t("jobs.labels.create.vehicleinfo"),
@@ -42,11 +40,9 @@ export default function JobsCreateComponent({ form }) {
const next = () => {
setPageIndex(pageIndex + 1);
console.log("Next");
};
const prev = () => {
setPageIndex(pageIndex - 1);
console.log("Previous");
};
const ProgressButtons = ({ top }) => {
@@ -79,17 +75,19 @@ export default function JobsCreateComponent({ form }) {
{pageIndex === steps.length - 1 && (
<Button
type="primary"
loading={isSubmitting}
onClick={() => {
form
.validateFields()
.then(() => {
// NO OP
form.submit();
})
.catch((error) => console.log("error", error));
form.submit();
.catch((error) => {
console.log("error", error);
});
}}
>
Done
{t("general.actions.done")}
</Button>
)}
</Space>
@@ -146,13 +144,11 @@ export default function JobsCreateComponent({ form }) {
) : (
<div>
<ProgressButtons top />
{errorMessage ? (
<div>
<AlertComponent title={errorMessage} type="error" />
</div>
) : null}
{steps.map((item, idx) => (
<div
key={idx}

View File

@@ -46,6 +46,7 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr
});
const [form] = Form.useForm();
const [state, setState] = contextState;
const [isSubmitting, setIsSubmitting] = useState(false);
const [insertJob] = useMutation(INSERT_NEW_JOB);
const [loadOwner, remoteOwnerData] = useLazyQuery(QUERY_OWNER_FOR_JOB_CREATION);
@@ -83,16 +84,19 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr
newJobId: resp.data.insert_jobs.returning[0].id
});
logImEXEvent("manual_job_create_completed", {});
setIsSubmitting(false);
})
.catch((error) => {
notification.error({
title: t("jobs.errors.creating", { error: error })
});
setState({ ...state, error: error });
setIsSubmitting(false);
});
};
const handleFinish = (values) => {
setIsSubmitting(true);
let job = Object.assign(
{},
values,
@@ -297,7 +301,7 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr
})
}}
>
<JobsCreateComponent form={form} />
<JobsCreateComponent form={form} isSubmitting={isSubmitting} />
</Form>
</RbacWrapper>
</JobCreateContext.Provider>

View File

@@ -30,6 +30,7 @@ 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";
const PrintCenterModalContainer = lazyDev(
() => import("../../components/print-center-modal/print-center-modal.container")
@@ -68,7 +69,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 +113,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({
@@ -178,6 +183,7 @@ export function Manage({ conflict, bodyshop, partsManagementOnly, isDarkMode, cu
<TaskUpsertModalContainer />
<BreadCrumbs />
<BillEnterModalContainer />
<EsignatureModalContainer />
<JobCostingModal />
<ReportCenterModal />
<EmailOverlayContainer />

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,6 +1,6 @@
import { combineReducers } from "redux";
import { persistReducer } from "redux-persist";
import storage from "redux-persist/lib/storage";
import storageModule from "redux-persist/lib/storage";
import { withReduxStateSync } from "redux-state-sync";
import applicationReducer from "./application/application.reducer";
import emailReducer from "./email/email.reducer";
@@ -11,6 +11,8 @@ import techReducer from "./tech/tech.reducer";
import userReducer from "./user/user.reducer";
import trelloReducer from "./trello/trello.reducer";
const storage = storageModule?.default ?? storageModule;
// const persistConfig = {
// key: "root",
// storage,

View File

@@ -305,7 +305,8 @@
"creatingdefaultview": "Error creating default view.",
"duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique",
"loading": "Unable to load shop details. Please call technical support.",
"saving": "Error encountered while saving. {{message}}"
"saving": "Error encountered while saving. {{message}}",
"task_preset_allocation_exceeded": "{{laborType}} task preset total is {{total}}% and cannot exceed 100%."
},
"fields": {
"ReceivableCustomField": "QBO Receivable Custom Field {{number}}",
@@ -519,6 +520,7 @@
"list-active": "Jobs -> List Active",
"list-all": "Jobs -> List All",
"list-ready": "Jobs -> List Ready",
"manual-line": "Jobs -> Manual Line",
"partsqueue": "Jobs -> Parts Queue",
"void": "Jobs -> Void"
},
@@ -1074,7 +1076,36 @@
"earlyrorequired.message": "This job requires an early Repair Order to be created before posting to Reynolds. Please use the admin panel to create the early RO first."
},
"labels": {
"refreshallocations": "Refresh to see DMS Allocations."
"refreshallocations": "Refresh to see DMS Allocations.",
"provider_reynolds": "Reynolds",
"provider_fortellis": "Fortellis",
"provider_cdk": "CDK",
"provider_pbs": "PBS",
"provider_dms": "DMS",
"transport_wss": "(WSS)",
"transport_ws": "(WS)",
"banner_status_connected": "Connected",
"banner_status_disconnected": "Disconnected",
"banner_message": "Posting to {{provider}} | {{transport}} | {{status}}",
"reconnected_export_service": "Reconnected to {{provider}} Export Service",
"rr_validation_message": "Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize.",
"rr_validation_notice_title": "Reynolds RO created",
"rr_validation_notice_description": "Complete validation in Reynolds, then click Finished/Close to finalize and mark this export complete.",
"color_json": "Color JSON",
"plain_json": "Plain JSON",
"collapse_all": "Collapse All",
"expand_all": "Expand All",
"log_level": "Log Level",
"clear_logs": "Clear Logs",
"reconnect": "Reconnect",
"details": "Details",
"hide_details": "Hide details",
"copy": "Copy",
"copied": "Copied",
"copy_request": "Copy Request",
"copy_response": "Copy Response",
"request_xml": "Request XML",
"response_xml": "Response XML"
}
},
"documents": {
@@ -1145,12 +1176,26 @@
"new": "New Team",
"newmember": "New Team Member"
},
"errors": {
"allocation_total_exact": "Team allocation must total exactly 100%.",
"duplicate_member": "Each employee can only appear once per team.",
"minimum_one_member": "Add at least one team member."
},
"fields": {
"active": "Active",
"allocation_percentage": "Allocation %",
"employeeid": "Employee",
"max_load": "Max Load",
"name": "Team Name",
"payout_method": "Payout Method",
"percentage": "Percent"
},
"labels": {
"allocation_total": "Allocation Total: {{total}}%"
},
"options": {
"commission_percentage": "Commission %",
"hourly": "Hourly"
}
},
"employees": {
@@ -1266,6 +1311,7 @@
"delete": "Delete",
"deleteall": "Delete All",
"deselectall": "Deselect All",
"done": "Done",
"download": "Download",
"edit": "Edit",
"gotoadmin": "Go to Admin Panel",
@@ -2157,6 +2203,7 @@
"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": "E-Signature",
"estimatelines": "Estimate Lines",
"estimator": "Estimator",
"existing_jobs": "Existing Jobs",
@@ -3343,8 +3390,10 @@
"void_ros": "Void ROs",
"work_in_progress_committed_labour": "Work in Progress - Committed Labor",
"work_in_progress_jobs": "Work in Progress - Jobs",
"work_in_progress_labour": "Work in Progress - Labor",
"work_in_progress_payables": "Work in Progress - Payables"
"work_in_progress_labour": "Work in Progress - Labor (Detail)",
"work_in_progress_labour_summary": "Work in Progress - Labor (Summary)",
"work_in_progress_payables": "Work in Progress - Payables (Detail)",
"work_in_progress_payables_summary": "Work in Progress - Payables (Summary)"
}
},
"schedule": {
@@ -3575,6 +3624,7 @@
"employee_team": "Employee Team",
"flat_rate": "Flat Rate?",
"memo": "Memo",
"pay": "Pay",
"productivehrs": "Productive Hours",
"ro_number": "Job to Post Against",
"task_name": "Task"

View File

@@ -305,7 +305,8 @@
"creatingdefaultview": "",
"duplicate_insurance_company": "",
"loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.",
"saving": ""
"saving": "",
"task_preset_allocation_exceeded": ""
},
"fields": {
"ReceivableCustomField": "",
@@ -519,6 +520,7 @@
"list-active": "",
"list-all": "",
"list-ready": "",
"manual-line": "",
"partsqueue": "",
"void": ""
},
@@ -1074,7 +1076,36 @@
"earlyrorequired.message": ""
},
"labels": {
"refreshallocations": ""
"refreshallocations": "",
"provider_reynolds": "",
"provider_fortellis": "",
"provider_cdk": "",
"provider_pbs": "",
"provider_dms": "",
"transport_wss": "",
"transport_ws": "",
"banner_status_connected": "",
"banner_status_disconnected": "",
"banner_message": "",
"reconnected_export_service": "",
"rr_validation_message": "",
"rr_validation_notice_title": "",
"rr_validation_notice_description": "",
"color_json": "",
"plain_json": "",
"collapse_all": "",
"expand_all": "",
"log_level": "",
"clear_logs": "",
"reconnect": "",
"details": "",
"hide_details": "",
"copy": "",
"copied": "",
"copy_request": "",
"copy_response": "",
"request_xml": "",
"response_xml": ""
}
},
"documents": {
@@ -1145,12 +1176,26 @@
"new": "",
"newmember": ""
},
"errors": {
"allocation_total_exact": "",
"duplicate_member": "",
"minimum_one_member": ""
},
"fields": {
"active": "",
"allocation_percentage": "",
"employeeid": "",
"max_load": "",
"name": "",
"payout_method": "",
"percentage": ""
},
"labels": {
"allocation_total": ""
},
"options": {
"commission_percentage": "",
"hourly": ""
}
},
"employees": {
@@ -1266,6 +1311,7 @@
"delete": "Borrar",
"deleteall": "",
"deselectall": "",
"done": "",
"download": "",
"edit": "Editar",
"gotoadmin": "",
@@ -2157,6 +2203,7 @@
"duplicateconfirm": "",
"emailaudit": "",
"employeeassignments": "",
"esignature": "",
"estimatelines": "",
"estimator": "",
"existing_jobs": "Empleos existentes",
@@ -3344,7 +3391,9 @@
"work_in_progress_committed_labour": "",
"work_in_progress_jobs": "",
"work_in_progress_labour": "",
"work_in_progress_payables": ""
"work_in_progress_labour_summary": "",
"work_in_progress_payables": "",
"work_in_progress_payables_summary": ""
}
},
"schedule": {
@@ -3575,6 +3624,7 @@
"employee_team": "",
"flat_rate": "",
"memo": "",
"pay": "",
"productivehrs": "",
"ro_number": "",
"task_name": ""

View File

@@ -305,7 +305,8 @@
"creatingdefaultview": "",
"duplicate_insurance_company": "",
"loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.",
"saving": ""
"saving": "",
"task_preset_allocation_exceeded": ""
},
"fields": {
"ReceivableCustomField": "",
@@ -519,6 +520,7 @@
"list-active": "",
"list-all": "",
"list-ready": "",
"manual-line": "",
"partsqueue": "",
"void": ""
},
@@ -1074,7 +1076,36 @@
"earlyrorequired.message": ""
},
"labels": {
"refreshallocations": ""
"refreshallocations": "",
"provider_reynolds": "",
"provider_fortellis": "",
"provider_cdk": "",
"provider_pbs": "",
"provider_dms": "",
"transport_wss": "",
"transport_ws": "",
"banner_status_connected": "",
"banner_status_disconnected": "",
"banner_message": "",
"reconnected_export_service": "",
"rr_validation_message": "",
"rr_validation_notice_title": "",
"rr_validation_notice_description": "",
"color_json": "",
"plain_json": "",
"collapse_all": "",
"expand_all": "",
"log_level": "",
"clear_logs": "",
"reconnect": "",
"details": "",
"hide_details": "",
"copy": "",
"copied": "",
"copy_request": "",
"copy_response": "",
"request_xml": "",
"response_xml": ""
}
},
"documents": {
@@ -1145,12 +1176,26 @@
"new": "",
"newmember": ""
},
"errors": {
"allocation_total_exact": "",
"duplicate_member": "",
"minimum_one_member": ""
},
"fields": {
"active": "",
"allocation_percentage": "",
"employeeid": "",
"max_load": "",
"name": "",
"payout_method": "",
"percentage": ""
},
"labels": {
"allocation_total": ""
},
"options": {
"commission_percentage": "",
"hourly": ""
}
},
"employees": {
@@ -1266,6 +1311,7 @@
"delete": "Effacer",
"deleteall": "",
"deselectall": "",
"done": "",
"download": "",
"edit": "modifier",
"gotoadmin": "",
@@ -2157,6 +2203,7 @@
"duplicateconfirm": "",
"emailaudit": "",
"employeeassignments": "",
"esignature": "",
"estimatelines": "",
"estimator": "",
"existing_jobs": "Emplois existants",
@@ -3344,7 +3391,9 @@
"work_in_progress_committed_labour": "",
"work_in_progress_jobs": "",
"work_in_progress_labour": "",
"work_in_progress_payables": ""
"work_in_progress_labour_summary": "",
"work_in_progress_payables": "",
"work_in_progress_payables_summary": ""
}
},
"schedule": {
@@ -3575,6 +3624,7 @@
"employee_team": "",
"flat_rate": "",
"memo": "",
"pay": "",
"productivehrs": "",
"ro_number": "",
"task_name": ""

View File

@@ -5,13 +5,15 @@ import { RetryLink } from "@apollo/client/link/retry";
import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
import { getMainDefinition } from "@apollo/client/utilities";
import apolloLogger from "apollo-link-logger";
import apolloLoggerModule from "apollo-link-logger";
import { createClient } from "graphql-ws";
import { map } from "rxjs/operators";
import { auth } from "../firebase/firebase.utils";
import errorLink from "../graphql/apollo-error-handling";
const apolloLogger = apolloLoggerModule?.default ?? apolloLoggerModule;
/**
* HTTP transport
*/

View File

@@ -1717,6 +1717,20 @@ export const TemplateList = (type, context) => {
group: "jobs",
featureNameRestricted: "timetickets"
},
work_in_progress_labour_summary: {
title: i18n.t("reportcenter.templates.work_in_progress_labour_summary"),
description: "",
subject: i18n.t("reportcenter.templates.work_in_progress_labour_summary"),
key: "work_in_progress_labour_summary",
//idtype: "vendor",
disabled: false,
rangeFilter: {
object: i18n.t("reportcenter.labels.objects.jobs"),
field: i18n.t("jobs.fields.date_open")
},
group: "jobs",
featureNameRestricted: "timetickets"
},
work_in_progress_committed_labour: {
title: i18n.t("reportcenter.templates.work_in_progress_committed_labour"),
description: "",
@@ -1746,6 +1760,20 @@ export const TemplateList = (type, context) => {
group: "jobs",
featureNameRestricted: "bills"
},
work_in_progress_payables_summary: {
title: i18n.t("reportcenter.templates.work_in_progress_payables_summary"),
description: "",
subject: i18n.t("reportcenter.templates.work_in_progress_payables_summary"),
key: "work_in_progress_payables_summary",
//idtype: "vendor",
disabled: false,
rangeFilter: {
object: i18n.t("reportcenter.labels.objects.jobs"),
field: i18n.t("jobs.fields.date_open")
},
group: "jobs",
featureNameRestricted: "bills"
},
lag_time: {
title: i18n.t("reportcenter.templates.lag_time"),
description: "",

View File

@@ -47,6 +47,33 @@ const httpsCerts = {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const pathSeparatorPattern = String.raw`[\\/]`;
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const packageChunkTest = (packageNames) => {
const names = Array.isArray(packageNames) ? packageNames : [packageNames];
return new RegExp(
`${pathSeparatorPattern}node_modules${pathSeparatorPattern}(?:${names
.map((name) => name.split("/").map(escapeRegex).join(pathSeparatorPattern))
.join("|")})(?:${pathSeparatorPattern}|$)`
);
};
const vendorCodeSplittingGroups = [
{ name: "antd", test: packageChunkTest("antd"), priority: 100 },
{ name: "react-redux", test: packageChunkTest("react-redux"), priority: 95 },
{ name: "redux", test: packageChunkTest("redux"), priority: 90 },
{ name: "lodash", test: packageChunkTest("lodash"), priority: 85 },
{ name: "@sentry/react", test: packageChunkTest("@sentry/react"), priority: 80 },
{ name: "@splitsoftware/splitio-react", test: packageChunkTest("@splitsoftware/splitio-react"), priority: 75 },
{ name: "logrocket", test: packageChunkTest("logrocket"), priority: 70 },
{ name: "firebase", test: packageChunkTest("@firebase"), priority: 65 },
{ name: "markerjs2", test: packageChunkTest("markerjs2"), priority: 60 },
{ name: "@apollo/client", test: packageChunkTest("@apollo/client"), priority: 55 },
{ name: "libphonenumber-js", test: packageChunkTest("libphonenumber-js"), priority: 50 },
{ name: "recharts", test: packageChunkTest("recharts"), priority: 45 }
];
export default defineConfig(({ command, mode }) => {
// React Compiler is always enabled for production/test builds
@@ -228,27 +255,13 @@ export default defineConfig(({ command, mode }) => {
build: {
sourcemap: true,
rollupOptions: {
rolldownOptions: {
output: {
manualChunks: {
antd: ["antd"],
"react-redux": ["react-redux"],
redux: ["redux"],
lodash: ["lodash"],
"@sentry/react": ["@sentry/react"],
"@splitsoftware/splitio-react": ["@splitsoftware/splitio-react"],
logrocket: ["logrocket"],
firebase: [
"@firebase/analytics",
"@firebase/app",
"@firebase/firestore",
"@firebase/auth",
"@firebase/messaging"
],
markerjs2: ["markerjs2"],
"@apollo/client": ["@apollo/client"],
"libphonenumber-js": ["libphonenumber-js"],
recharts: ["recharts"]
codeSplitting: {
groups: vendorCodeSplittingGroups
},
comments: {
legal: false
}
}
},
@@ -256,12 +269,6 @@ export default defineConfig(({ command, mode }) => {
cssMinify: "lightningcss"
},
// Strip console/debugger in prod to shrink bundles
esbuild: {
// drop: mode === "production" ? ["console", "debugger"] : [],
legalComments: "none" // Remove license comments in production
},
optimizeDeps: {
include: [
"react",
@@ -284,8 +291,8 @@ export default defineConfig(({ command, mode }) => {
"@firebase/util",
"styled-components"
],
esbuildOptions: {
loader: { ".jsx": "jsx", ".tsx": "tsx" }
rolldownOptions: {
moduleTypes: { ".jsx": "jsx", ".tsx": "tsx" }
},
// Force styled-components to be pre-bundled and deduplicated
force: mode === "development"

View File

@@ -2156,10 +2156,12 @@
- active:
_eq: true
columns:
- commission_rates
- created_at
- employeeid
- id
- labor_rates
- payout_method
- percentage
- teamid
- updated_at
@@ -2167,10 +2169,12 @@
- role: user
permission:
columns:
- commission_rates
- created_at
- employeeid
- id
- labor_rates
- payout_method
- percentage
- teamid
- updated_at
@@ -2188,10 +2192,12 @@
- role: user
permission:
columns:
- commission_rates
- created_at
- employeeid
- id
- labor_rates
- payout_method
- percentage
- teamid
- updated_at
@@ -6506,6 +6512,7 @@
- id
- jobid
- memo
- payout_context
- productivehrs
- rate
- task_name
@@ -6531,6 +6538,7 @@
- id
- jobid
- memo
- payout_context
- productivehrs
- rate
- task_name
@@ -6565,6 +6573,7 @@
- id
- jobid
- memo
- payout_context
- productivehrs
- rate
- task_name
@@ -6748,6 +6757,7 @@
- id
- jobid
- memo
- payout_context
- productivehrs
- rate
- updated_at
@@ -6768,6 +6778,7 @@
- id
- jobid
- memo
- payout_context
- productivehrs
- rate
- updated_at
@@ -6798,6 +6809,7 @@
- id
- jobid
- memo
- payout_context
- productivehrs
- rate
- updated_at

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"."employee_team_members" add column "payout_method" text
-- null;

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
alter table "public"."employee_team_members" add column "commission_rates" jsonb
null;

View File

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

View File

@@ -0,0 +1,2 @@
alter table "public"."timetickets" add column "payout_context" jsonb
null;

View File

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

View File

@@ -0,0 +1,2 @@
alter table "public"."tt_approval_queue" add column "payout_context" jsonb
null;

3110
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,25 +18,27 @@
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
},
"dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.997.0",
"@aws-sdk/client-elasticache": "^3.997.0",
"@aws-sdk/client-s3": "^3.997.0",
"@aws-sdk/client-secrets-manager": "^3.997.0",
"@aws-sdk/client-ses": "^3.997.0",
"@aws-sdk/client-sqs": "^3.997.0",
"@aws-sdk/client-textract": "^3.997.0",
"@aws-sdk/credential-provider-node": "^3.972.12",
"@aws-sdk/lib-storage": "^3.997.0",
"@aws-sdk/s3-request-presigner": "^3.997.0",
"@aws-sdk/client-cloudwatch-logs": "^3.1009.0",
"@aws-sdk/client-elasticache": "^3.1009.0",
"@aws-sdk/client-s3": "^3.1009.0",
"@aws-sdk/client-secrets-manager": "^3.1009.0",
"@aws-sdk/client-ses": "^3.1009.0",
"@aws-sdk/client-sqs": "^3.1009.0",
"@aws-sdk/client-textract": "^3.1009.0",
"@aws-sdk/credential-provider-node": "^3.972.21",
"@aws-sdk/lib-storage": "^3.1009.0",
"@aws-sdk/s3-request-presigner": "^3.1009.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",
"archiver": "^7.0.1",
"aws4": "^1.13.2",
"axios": "^1.13.5",
"axios": "^1.13.6",
"axios-curlirize": "^2.0.0",
"better-queue": "^3.8.12",
"bullmq": "^5.70.1",
"bullmq": "^5.71.0",
"chart.js": "^4.5.1",
"cloudinary": "^2.9.0",
"compression": "^1.8.1",
@@ -46,20 +48,20 @@
"dinero.js": "^1.9.1",
"dotenv": "^17.3.1",
"express": "^4.21.1",
"fast-xml-parser": "^5.4.1",
"firebase-admin": "^13.6.1",
"fast-xml-parser": "^5.5.6",
"firebase-admin": "^13.7.0",
"fuse.js": "^7.1.0",
"graphql": "^16.13.0",
"graphql-request": "^6.1.0",
"graphql": "^16.13.1",
"graphql-request": "^7.4.0",
"intuit-oauth": "^4.2.2",
"ioredis": "^5.9.3",
"ioredis": "^5.10.0",
"json-2-csv": "^5.5.10",
"jsonwebtoken": "^9.0.3",
"juice": "^11.1.1",
"lodash": "^4.17.23",
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
"multer": "^2.0.2",
"multer": "^2.1.1",
"mustache": "^4.2.0",
"node-persist": "^4.0.4",
"nodemailer": "^6.10.0",
@@ -69,15 +71,15 @@
"recursive-diff": "^1.0.9",
"rimraf": "^6.1.3",
"skia-canvas": "^3.0.8",
"soap": "^1.7.1",
"soap": "^1.8.0",
"socket.io": "^4.8.3",
"socket.io-adapter": "^2.5.6",
"ssh2-sftp-client": "^11.0.0",
"twilio": "^5.12.2",
"twilio": "^5.13.0",
"uuid": "^11.1.0",
"winston": "^3.19.0",
"winston-cloudwatch": "^6.3.0",
"xml-formatter": "^3.6.7",
"xml-formatter": "^3.7.0",
"xml2js": "^0.6.2",
"xmlbuilder2": "^4.0.3",
"yazl": "^3.3.1"
@@ -86,11 +88,11 @@
"@eslint/js": "^9.39.2",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.3.0",
"globals": "^17.4.0",
"mock-require": "^3.0.3",
"p-limit": "^3.1.0",
"prettier": "^3.8.1",
"supertest": "^7.2.2",
"vitest": "^4.0.18"
"vitest": "^4.1.0"
}
}

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

@@ -130,12 +130,13 @@ exports.default = async (req, res) => {
async function QueryVendorRecord(oauthClient, qbo_realmId, req, bill) {
try {
const url = urlBuilder(
qbo_realmId,
"query",
`select * From vendor where DisplayName = '${StandardizeName(bill.vendor.name)}'`
);
const result = await oauthClient.makeApiCall({
url: urlBuilder(
qbo_realmId,
"query",
`select * From vendor where DisplayName = '${StandardizeName(bill.vendor.name)}'`
),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -150,6 +151,11 @@ async function QueryVendorRecord(oauthClient, qbo_realmId, req, bill) {
bodyshopid: bill.job.shopid,
email: req.user.email
});
logger.log("qbo-payables-query", "DEBUG", req.user.email, null, {
method: "QueryVendorRecord",
call: url,
result: result.json
});
return result.json?.QueryResponse?.Vendor?.[0];
} catch (error) {
@@ -167,8 +173,9 @@ async function InsertVendorRecord(oauthClient, qbo_realmId, req, bill) {
DisplayName: StandardizeName(bill.vendor.name)
};
try {
const url = urlBuilder(qbo_realmId, "vendor");
const result = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "vendor"),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -184,6 +191,12 @@ async function InsertVendorRecord(oauthClient, qbo_realmId, req, bill) {
bodyshopid: bill.job.shopid,
email: req.user.email
});
logger.log("qbo-payments-insert", "DEBUG", req.user.email, null, {
method: "InsertVendorRecord",
call: url,
Vendor: Vendor,
result: result.json
});
if (result.status >= 400) {
throw new Error(JSON.stringify(result.json.Fault));
@@ -274,11 +287,12 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
VendorRef: {
value: vendor.Id
},
...(vendor.TermRef && !bill.is_credit_memo && {
SalesTermRef: {
value: vendor.TermRef.value
}
}),
...(vendor.TermRef &&
!bill.is_credit_memo && {
SalesTermRef: {
value: vendor.TermRef.value
}
}),
TxnDate: moment(bill.date)
//.tz(bill.job.bodyshop.timezone)
.format("YYYY-MM-DD"),
@@ -318,8 +332,9 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
[logKey]: logValue
});
try {
const url = urlBuilder(qbo_realmId, bill.is_credit_memo ? "vendorcredit" : "bill");
const result = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, bill.is_credit_memo ? "vendorcredit" : "bill"),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -335,6 +350,12 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
bodyshopid: bill.job.shopid,
email: req.user.email
});
logger.log("qbo-payables-insert", "DEBUG", req.user.email, null, {
method: "InsertBill",
call: url,
postingObj: bill.is_credit_memo ? VendorCredit : billQbo,
result: result.json
});
if (result.status >= 400) {
throw new Error(JSON.stringify(result.json.Fault));

View File

@@ -82,14 +82,7 @@ exports.default = async (req, res) => {
if (isThreeTier || (!isThreeTier && twoTierPref === "name")) {
//Insert the name/owner and account for whether the source should be the ins co in 3 tier..
ownerCustomerTier = await QueryOwner(
oauthClient,
qbo_realmId,
req,
payment.job,
isThreeTier,
insCoCustomerTier
);
ownerCustomerTier = await QueryOwner(oauthClient, qbo_realmId, req, payment.job, insCoCustomerTier);
//Query for the owner itself.
if (!ownerCustomerTier) {
ownerCustomerTier = await InsertOwner(
@@ -229,8 +222,9 @@ async function InsertPayment(oauthClient, qbo_realmId, req, payment, parentRef)
paymentQbo
});
try {
const url = urlBuilder(qbo_realmId, "payment");
const result = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "payment"),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -246,6 +240,12 @@ async function InsertPayment(oauthClient, qbo_realmId, req, payment, parentRef)
bodyshopid: payment.job.shopid,
email: req.user.email
});
logger.log("qbo-payments-insert", "DEBUG", req.user.email, null, {
method: "InsertPayment",
call: url,
paymentQbo: paymentQbo,
result: result.json
});
if (result.status >= 400) {
throw new Error(JSON.stringify(result.json.Fault));
@@ -428,8 +428,9 @@ async function InsertCreditMemo(oauthClient, qbo_realmId, req, payment, parentRe
paymentQbo
});
try {
const url = urlBuilder(qbo_realmId, "creditmemo");
const result = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "creditmemo"),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -445,6 +446,12 @@ async function InsertCreditMemo(oauthClient, qbo_realmId, req, payment, parentRe
bodyshopid: req.user.bodyshopid,
email: req.user.email
});
logger.log("qbo-metadata-query", "DEBUG", req.user.email, null, {
method: "InsertCreditMemo",
call: url,
paymentQbo: paymentQbo,
result: result.json
});
if (result.status >= 400) {
throw new Error(JSON.stringify(result.json.Fault));

View File

@@ -213,12 +213,13 @@ exports.default = async (req, res) => {
async function QueryInsuranceCo(oauthClient, qbo_realmId, req, job) {
try {
const url = urlBuilder(
qbo_realmId,
"query",
`select * From Customer where DisplayName = '${StandardizeName(job.ins_co_nm.trim())}' and Active = true`
);
const result = await oauthClient.makeApiCall({
url: urlBuilder(
qbo_realmId,
"query",
`select * From Customer where DisplayName = '${StandardizeName(job.ins_co_nm.trim())}' and Active = true`
),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -233,6 +234,11 @@ async function QueryInsuranceCo(oauthClient, qbo_realmId, req, job) {
jobid: job.id,
email: req.user.email
});
logger.log("qbo-receivables-query", "DEBUG", req.user.email, job.id, {
method: "QueryInsuranceCo",
call: url,
result: result.json
});
return result.json?.QueryResponse?.Customer?.[0];
} catch (error) {
@@ -266,8 +272,9 @@ async function InsertInsuranceCo(oauthClient, qbo_realmId, req, job, bodyshop) {
}
};
try {
const url = urlBuilder(qbo_realmId, "customer");
const result = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "customer"),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -283,6 +290,12 @@ async function InsertInsuranceCo(oauthClient, qbo_realmId, req, job, bodyshop) {
jobid: job.id,
email: req.user.email
});
logger.log("qbo-receivables-insert", "DEBUG", req.user.email, job.id, {
method: "InsertInsuranceCo",
call: url,
customerObj: Customer,
result: result.json
});
return result.json?.Customer;
} catch (error) {
@@ -298,12 +311,13 @@ exports.InsertInsuranceCo = InsertInsuranceCo;
async function QueryOwner(oauthClient, qbo_realmId, req, job, parentTierRef) {
const ownerName = generateOwnerTier(job, true, null);
const url = urlBuilder(
qbo_realmId,
"query",
`select * From Customer where DisplayName = '${StandardizeName(ownerName)}' and Active = true`
);
const result = await oauthClient.makeApiCall({
url: urlBuilder(
qbo_realmId,
"query",
`select * From Customer where DisplayName = '${StandardizeName(ownerName)}' and Active = true`
),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -318,6 +332,11 @@ async function QueryOwner(oauthClient, qbo_realmId, req, job, parentTierRef) {
jobid: job.id,
email: req.user.email
});
logger.log("qbo-receivables-query", "DEBUG", req.user.email, job.id, {
method: "QueryOwner",
call: url,
result: result.json
});
return result.json?.QueryResponse?.Customer?.find((x) => x.ParentRef?.value === parentTierRef?.Id);
}
@@ -347,8 +366,9 @@ async function InsertOwner(oauthClient, qbo_realmId, req, job, isThreeTier, pare
: {})
};
try {
const url = urlBuilder(qbo_realmId, "customer");
const result = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "customer"),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -364,6 +384,12 @@ async function InsertOwner(oauthClient, qbo_realmId, req, job, isThreeTier, pare
jobid: job.id,
email: req.user.email
});
logger.log("qbo-receivables-insert", "DEBUG", req.user.email, job.id, {
method: "InsertOwner",
call: url,
customerObj: Customer,
result: result.json
});
return result.json?.Customer;
} catch (error) {
@@ -378,12 +404,13 @@ async function InsertOwner(oauthClient, qbo_realmId, req, job, isThreeTier, pare
exports.InsertOwner = InsertOwner;
async function QueryJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
const url = urlBuilder(
qbo_realmId,
"query",
`select * From Customer where DisplayName = '${job.ro_number}' and Active = true`
);
const result = await oauthClient.makeApiCall({
url: urlBuilder(
qbo_realmId,
"query",
`select * From Customer where DisplayName = '${job.ro_number}' and Active = true`
),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -398,6 +425,11 @@ async function QueryJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
jobid: job.id,
email: req.user.email
});
logger.log("qbo-receivables-query", "DEBUG", req.user.email, job.id, {
method: "QueryJob",
call: url,
result: result.json
});
const customers = result.json?.QueryResponse?.Customer;
return customers && (parentTierRef ? customers.find((x) => x.ParentRef.value === parentTierRef.Id) : customers[0]);
@@ -423,8 +455,9 @@ async function InsertJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
}
};
try {
const url = urlBuilder(qbo_realmId, "customer");
const result = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "customer"),
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -440,6 +473,12 @@ async function InsertJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
jobid: job.id,
email: req.user.email
});
logger.log("qbo-receivables-insert", "DEBUG", req.user.email, job.id, {
method: "InsertJob",
call: url,
customerObj: Customer,
result: result.json
});
if (result.status >= 400) {
throw new Error(JSON.stringify(result.json.Fault));

View File

@@ -1,12 +1,12 @@
const Fuse = require('fuse.js');
const { has } = require("lodash");
const { standardizedFieldsnames } = require('./bill-ocr-normalize');
const InstanceManager = require("../../utils/instanceMgr").default;
const PRICE_PERCENT_MARGIN_TOLERANCE = 0.5; //Used to make sure prices and costs are likely.
const PRICE_QUANTITY_MARGIN_TOLERANCE = 0.03; //Used to make sure that if there is a quantity, the price is likely a unit price.
// Helper function to normalize fields
const normalizePartNumber = (str) => {
return str.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
@@ -17,7 +17,38 @@ const normalizeText = (str) => {
};
const normalizePrice = (str) => {
if (typeof str !== 'string') return str;
return str.replace(/[^0-9.-]+/g, "");
let value = str.trim();
// Handle European-style decimal comma like "292,37".
// Only treat the *last* comma as a decimal separator when:
// - there's no '.' anywhere (so we don't fight normal US formatting like "1,234.56")
// - and the suffix after the last comma is 1-2 digits (so "1,234" stays 1234)
if (!value.includes('.') && value.includes(',')) {
const lastCommaIndex = value.lastIndexOf(',');
const decimalSuffix = value.slice(lastCommaIndex + 1).trim();
if (/^\d{1,2}$/.test(decimalSuffix)) {
const before = value.slice(0, lastCommaIndex).replace(/,/g, '');
value = `${before}.${decimalSuffix}`;
} else {
// Treat commas as thousands separators (or noise) and drop them.
value = value.replace(/,/g, '');
}
}
return value.replace(/[^0-9.-]+/g, "");
};
const roundToIncrement = (value, increment) => {
if (typeof value !== 'number' || !isFinite(value) || typeof increment !== 'number' || !isFinite(increment) || increment <= 0) {
return value;
}
const rounded = Math.round((value + Number.EPSILON) / increment) * increment;
// Prevent float artifacts (e.g. 0.20500000000000002)
const decimals = Math.max(0, Math.ceil(-Math.log10(increment)));
return parseFloat(rounded.toFixed(decimals));
};
//More complex function. Not necessary at the moment, keeping for reference.
@@ -134,6 +165,7 @@ const calculateTextractConfidence = (textractLineItem) => {
const hasActualCost = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.actual_cost);
const hasActualPrice = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.actual_price);
const hasLineDesc = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.line_desc);
const hasQuantity = textractLineItem?.QUANTITY?.value; //We don't normalize quantity, we just use what textract gives us.
// Calculate weighted average, giving more weight to important fields
// If we can identify key fields (ITEM, PRODUCT_CODE, PRICE), weight them higher
@@ -173,10 +205,11 @@ const calculateTextractConfidence = (textractLineItem) => {
if (!hasActualCost) missingCount++;
if (!hasActualPrice) missingCount++;
if (!hasLineDesc) missingCount++;
if (!hasQuantity) missingCount++;
// Each missing field reduces confidence by 15%
// Each missing field reduces confidence by 20%
if (missingCount > 0) {
missingFieldsPenalty = 1.0 - (missingCount * 0.15);
missingFieldsPenalty = 1.0 - (missingCount * 0.20);
}
avgConfidence = avgConfidence * missingFieldsPenalty;
@@ -361,16 +394,16 @@ async function generateBillFormData({ processedData, jobid: jobidFromProps, body
const joblineMatches = joblineFuzzySearch({ fuseToSearch: jobLineDescFuse, processedData });
const vendorFuse = new Fuse(
jobData.vendors,
jobData.vendors.map(v => ({ ...v, name_normalized: normalizeText(v.name) })),
{
keys: ['name'],
threshold: 0.4, //Adjust as needed for matching sensitivity,
keys: [{ name: "name", weight: 3 }, { name: 'name_normalized', weight: 2 }],
threshold: 0.4,
includeScore: true,
},
}
);
const vendorMatches = vendorFuse.search(processedData.summary?.VENDOR_NAME?.value || processedData.summary?.NAME?.value);
const vendorMatches = vendorFuse.search(normalizeText(processedData.summary?.VENDOR_NAME?.value || processedData.summary?.NAME?.value));
let vendorid;
if (vendorMatches.length > 0) {
@@ -381,6 +414,21 @@ async function generateBillFormData({ processedData, jobid: jobidFromProps, body
throw new Error('Job not found for bill form data generation.');
}
//Is there a subtotal level discount? If there is, we need to figure out what the percentage is, and apply that to the actual cost as a reduction
const subtotalDiscountValueRaw = processedData.summary?.DISCOUNT?.value || processedData.summary?.SUBTOTAL_DISCOUNT?.value || 0;
let discountPercentageDecimal = 0;
if (subtotalDiscountValueRaw) {
const subtotal = parseFloat(normalizePrice(processedData.summary?.SUBTOTAL?.value || 0)) || 0;
const subtotalDiscountValue = parseFloat(normalizePrice(subtotalDiscountValueRaw)) || 0;
if (subtotal > 0 && subtotalDiscountValue) {
// Store discount percentage as a decimal (e.g. 20.5% => 0.205),
// but only allow half-percent increments (0.005 steps).
discountPercentageDecimal = Math.abs(subtotalDiscountValue / subtotal);
discountPercentageDecimal = roundToIncrement(discountPercentageDecimal, 0.005);
}
}
//TODO: How do we handle freight lines and core charges?
//Create the form data structure for the bill posting screen.
const billFormData = {
@@ -448,6 +496,31 @@ async function generateBillFormData({ processedData, jobid: jobidFromProps, body
}
}
//If there's nothing, just fall back to seeing if there's a price object from textract.
if (!actualPrice && textractLineItem.PRICE) {
actualPrice = textractLineItem.PRICE.value;
}
if (!actualCost && textractLineItem.PRICE) {
actualCost = textractLineItem.PRICE.value;
}
//If quantity greater than 1, check if the actual cost is a multiple of the actual price, if so, divide it out to get the unit price.
const quantity = parseInt(textractLineItem?.QUANTITY?.value);
if (quantity && quantity > 1) {
if (actualPrice && quantity && Math.abs((actualPrice / quantity) - (parseFloat(matchToUse?.item?.act_price) || 0)) / ((parseFloat(matchToUse?.item?.act_price) || 1)) < PRICE_QUANTITY_MARGIN_TOLERANCE) {
actualPrice = actualPrice / quantity;
}
if (actualCost && quantity && Math.abs((actualCost / quantity) - (parseFloat(matchToUse?.item?.act_price) || 0)) / ((parseFloat(matchToUse?.item?.act_price) || 1)) < PRICE_QUANTITY_MARGIN_TOLERANCE) {
actualCost = actualCost / quantity;
}
}
if (discountPercentageDecimal > 0) {
actualCost = actualCost * (1 - discountPercentageDecimal);
}
const responsibilityCenters = job.bodyshop.md_responsibility_centers
//TODO: Do we need to verify the lines to see if it is a unit price or total price (i.e. quantity * price)
const lineObject = {
@@ -714,5 +787,6 @@ const bodyshopHasDmsKey = (bodyshop) =>
module.exports = {
generateBillFormData
generateBillFormData,
normalizePrice
}

View File

@@ -50,10 +50,12 @@ function normalizeLabelName(labelText) {
'unit_price': standardizedFieldsnames.actual_price,
'list': standardizedFieldsnames.actual_price,
'retail_price': standardizedFieldsnames.actual_price,
'retail': standardizedFieldsnames.actual_price,
'net': standardizedFieldsnames.actual_cost,
'selling_price': standardizedFieldsnames.actual_cost,
'net_price': standardizedFieldsnames.actual_cost,
'net_cost': standardizedFieldsnames.actual_cost,
'total': standardizedFieldsnames.actual_cost,
'po_no': standardizedFieldsnames.ro_number,
'customer_po_no': standardizedFieldsnames.ro_number,
'customer_po_no_': standardizedFieldsnames.ro_number

View File

@@ -6,6 +6,7 @@ const { getTextractJobKey, setTextractJob, getTextractJob, getFileType, getPdfPa
const { extractInvoiceData, processScanData } = require("./bill-ocr-normalize");
const { generateBillFormData } = require("./bill-ocr-generator");
const logger = require("../../utils/logger");
const _ = require("lodash");
// Initialize AWS clients
const awsConfig = {
@@ -66,7 +67,7 @@ async function handleBillOcr(req, res) {
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, { ...processedData, billForm });
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ..._.omit(processedData, "originalTextractResponse"), billForm });
return res.status(200).json({
success: true,
@@ -82,7 +83,7 @@ async function handleBillOcr(req, res) {
// 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, { ...processedData, billForm });
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ..._.omit(processedData, "originalTextractResponse"), billForm });
return res.status(200).json({
success: true,
status: 'COMPLETED',

View File

@@ -66,7 +66,12 @@ exports.default = async function ReloadCdkMakes(req, res) {
} catch (error) {
logger.log("cdk-replace-makes-models-error", "ERROR", req.user.email, null, {
cdk_dealerid,
error
error: {
message: error?.message,
stack: error?.stack,
name: error?.name,
code: error?.code
}
});
res.status(500).json(error);
}
@@ -105,7 +110,12 @@ async function GetCdkMakes(req, cdk_dealerid) {
} catch (error) {
logger.log("cdk-replace-makes-models-error", "ERROR", req.user.email, null, {
cdk_dealerid,
error
error: {
message: error?.message,
stack: error?.stack,
name: error?.name,
code: error?.code
}
});
throw new Error(error);
@@ -141,7 +151,12 @@ async function GetFortellisMakes(req, cdk_dealerid) {
} catch (error) {
logger.log("fortellis-replace-makes-models-error", "ERROR", req.user.email, null, {
cdk_dealerid,
error
error: {
message: error?.message,
stack: error?.stack,
name: error?.name,
code: error?.code
}
});
throw new Error(error);

View File

@@ -250,6 +250,8 @@ const CreateRepairOrderTag = (job, errorCallback) => {
},
InsuranceCompany: job.ins_co_nm || "",
Claim: job.clm_no || "",
Deductible: job.ded_amt || 0,
PolicyNo: job.policy_no || "",
DMSAllocation: job.dms_allocation || "",
Contacts: {
CSR: job.employee_csr_rel

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

@@ -0,0 +1,304 @@
const { Documenso } = require("@documenso/sdk-typescript");
const axios = require("axios");
const { jsrAuthString } = require("../utils/utils");
const logger = require("../utils/logger");
const DOCUMENSO_API_KEY = "api_asojim0czruv13ud";//Done on a by team basis,
const documenso = new Documenso({
apiKey: DOCUMENSO_API_KEY,//Done on a by team basis,
serverURL: "https://stg-app.documenso.com/api/v2",
});
const JSR_SERVER = "https://reports.test.imex.online";
const jsreport = require("@jsreport/nodejs-client");
const { QUERY_JOB_FOR_SIGNATURE, INSERT_ESIG_AUDIT_TRAIL } = require("../graphql-client/queries");
async function distributeDocument(req, res) {
try {
const client = req.userGraphQLClient;
const { documentId } = req.body;
const distributeResult = await documenso.documents.distribute({
documentId,
});
const auditEntry = 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}) distributed to recipients.`,
useremail: req.user?.email,
type: 'esig-distribute'
}
})
res.json({ success: true, distributeResult });
} catch (error) {
console.error("Error distributing document:", error?.data);
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." });
}
}
async function deleteDocument(req, res) {
try {
const { documentId } = req.body;
//TODO: This needs to be hardened to prevent deleting other people's documents, completed ones, etc.
const deleteResult = await documenso.documents.delete({
documentId
});
res.json({ success: true, deleteResult });
} catch (error) {
console.error("Error deleting document:", error?.data);
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 newEsignDocument(req, res) {
try {
const client = req.userGraphQLClient;
const { bodyshop } = req.body
const { pdf: fileBuffer, esigData } = await RenderTemplate({ client, req })
const fileBlob = new Blob([fileBuffer], { type: "application/pdf" });
//Get the Job data.
const { jobs_by_pk: jobData } = await client.request(QUERY_JOB_FOR_SIGNATURE, { jobid: req.body.jobid });
const createDocumentResponse = await documenso.documents.create({
payload: {
title: esigData?.title,
externalId: `${req.body.jobid}|${req.user?.email}`, //Have to pass the uploaded by later on. Limited to 255 chars.
recipients: [
{
email: "patrick@imexsystems.ca",//jobData.ownr_ea,
name: `${jobData.ownr_fn} ${jobData.ownr_ln}`,
role: "SIGNER",
}
],
meta: {
timezone: bodyshop.timezone,
dateFormat: "MM/dd/yyyy hh:mm a",
language: "en",
subject: esigData?.subject,
message: esigData?.message,
}
},
file: fileBlob
});
const documentResult = await documenso.documents.get({
documentId: createDocumentResponse.id,
});
if (esigData?.fields && esigData.fields.length > 0) {
try {
await documenso.envelopes.fields.createMany({
envelopeId: createDocumentResponse.envelopeId,
data: esigData.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({})
//add to job audit trail.
const auditEntry = await client.request(INSERT_ESIG_AUDIT_TRAIL, {
obj: {
jobid: req.body.jobid,
bodyshopid: bodyshop.id,
operation: `Esignature document created. Subject: ${esigData?.subject || "No subject"}, Message: ${esigData?.message || "No message"}. Document ID: ${createDocumentResponse.id} Envlope ID: ${createDocumentResponse.envelopeId}`,
useremail: req.user?.email,
type: 'esig-create'
}
})
res.json({ token: presignToken.token, documentId: createDocumentResponse.id, envelopeId: createDocumentResponse.envelopeId });
}
catch (error) {
logger.log(`esig-new-error`, "ERROR", "esig", "api", {
message: error.message, stack: error.stack,
body: req.body
});
res.status(500).json({ error: "An error occurred while creating the e-sign document." });
}
}
async function RenderTemplate({ req }) {
//TODO Refactor to pull
const jsrAuth = jsrAuthString()
const jsreportClient = new jsreport("https://reports.test.imex.online", 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,
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) {
console.log("Error parsing esig data", error);
parsedEsigData = {}
}
return {
contextData,
useShopSpecificTemplate,
shopSpecificFolder,
esigData: parsedEsigData
};
// }
// return await generateTemplate(templateQueryToExecute, templateObject, useShopSpecificTemplate, shopSpecificFolder);
};
module.exports = {
newEsignDocument,
distributeDocument,
deleteDocument
}
// const sample_esig_for_jsr = {
// "fields": [
// {
// "placeholder": "[[signature]]",
// "type": "SIGNATURE"
// },
// {
// "placeholder": "[[date]]",
// "type": "DATE"
// }
// ],
// "subject": "CASL Auth Set in JSR",
// "message": "CASL Message set in JSR",
// "title": "CASL Title set in JSR"
// }

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

@@ -0,0 +1,393 @@
const { Documenso } = require("@documenso/sdk-typescript");
const fs = require("fs");
const path = require("path");
const logger = require("../utils/logger");
const { QUERY_META_FOR_ESIG_COMPLETION, INSERT_ESIGNATURE_DOCUMENT, INSERT_ESIG_AUDIT_TRAIL } = require("../graphql-client/queries");
const { uploadFileBuffer } = require("../media/imgproxy-media");
const client = require('../graphql-client/graphql-client').client;
const documenso = new Documenso({
apiKey: "api_asojim0czruv13ud",//Done on a by team basis,
serverURL: "https://stg-app.documenso.com/api/v2",
});
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",
}
async function esignWebhook(req, res) {
console.log("Esign Webhook Received:", req.body);
try {
const message = req.body
logger.log(`esig-webhook-received`, "DEBUG", "redis", "api", {
event: message.event,
body: message
});
switch (message.event) {
case webhookTypeEnums.DOCUMENT_CREATED:
//This is largely a throwaway event we know it was created.
console.log("Document created event received. Document ID:", message.payload.documentId);
// Here you can add any additional processing you want to do when a document is created
break;
case webhookTypeEnums.DOCUMENT_COMPLETED:
console.log("Document completed event received. Document ID:", message.payload.documentId);
await handleDocumentCompleted(message.payload);
// Here you can add any additional processing you want to do when a document is completed
break;
case webhookTypeEnums.DOCUMENT_SIGNED:
console.log("Document signed event received. Document ID:", message.payload.documentId);
// Here you can add any additional processing you want to do when a document is signed
break;
default:
console.log(`Unhandled event type: ${message.event}`);
}
// const result = await documenso.documents.download({
// documentId: req.body.payload.id,
// });
// result.resultingBuffer = Buffer.from(result.resultingArrayBuffer);
// // Save the document to a file for testing purposes
// const downloadsDir = path.join(__dirname, '../downloads');
// if (!fs.existsSync(downloadsDir)) {
// fs.mkdirSync(downloadsDir, { recursive: true });
// }
// const filePath = path.join(downloadsDir, `document_${req.body.payload.id}.pdf`);
// fs.writeFileSync(filePath, result.resultingBuffer);
// console.log(result)
res.sendStatus(200)
} catch (error) {
logger.log(`esig-webhook-error`, "ERROR", "redis", "api", {
message: error.message, stack: error.stack,
body: req.body
});
// const downloadsDir = path.join(__dirname, '../downloads');
// if (!fs.existsSync(downloadsDir)) {
// fs.mkdirSync(downloadsDir, { recursive: true });
// }
// const filePath = path.join(downloadsDir, `document_${req.body.payload.id}.pdf`);
// fs.writeFileSync(filePath, Buffer.from(err.body));
// console.error("Error handling esign webhook:", err);
res.sendStatus(500)
}
}
async function handleDocumentCompleted(payload = sampleComplete) {
//Check if the bodyshop is on image proxy or not
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
});
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`;
if (jobs_by_pk?.bodyshop?.uselocalmediaserver) {
//LMS not yet implemented.
} else {
//S3 Upload
const uploadResult = await uploadFileBuffer({ key, buffer, contentType: "application/pdf" });
if (!uploadResult.success) {
logger.log(`esig-webhook-s3-upload-error`, "ERROR", "redis", "api", {
message: uploadResult.message,
stack: uploadResult.stack,
jobid: jobid,
documentId: payload.id
});
} else {
logger.log(`esig-webhook-s3-upload-success`, "INFO", "redis", "api", {
jobid: jobid,
documentId: payload.id,
s3Key: key,
bucket: uploadResult.bucket
});
const auditEntry = await client.request(INSERT_ESIG_AUDIT_TRAIL, {
obj: {
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'
}
})
//insert the document record with the s3 key and bucket info.
await client.request(INSERT_ESIGNATURE_DOCUMENT, {
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(),
}
})
}
}
} catch (error) {
logger.log(`esig-webhook-event-completed-error`, "ERROR", "redis", "api", {
message: error.message, stack: error.stack,
payload
});
}
}
module.exports = {
esignWebhook
}
const sampleComplete = {
"id": 10929,
"title": "CASL Title set in JSR",
"source": "DOCUMENT",
"status": "COMPLETED",
"teamId": 742,
"userId": 654,
"Recipient": [
{
"id": 24997,
"name": "James Tschetter",
"role": "SIGNER",
"email": "patrick@imexsystems.ca",
"token": "uMom0GwL29NBqMfohGpUE",
"signedAt": "2026-02-27T22:11:52.835Z",
"expiresAt": "2026-05-28T22:10:48.991Z",
"documentId": 10929,
"readStatus": "OPENED",
"sendStatus": "SENT",
"templateId": null,
"authOptions": {
"accessAuth": [],
"actionAuth": []
},
"signingOrder": null,
"signingStatus": "SIGNED",
"rejectionReason": null,
"documentDeletedAt": null,
"expirationNotifiedAt": null
}
],
"createdAt": "2026-02-27T22:10:10.580Z",
"deletedAt": null,
"updatedAt": "2026-02-27T22:11:57.753Z",
"externalId": null,
"formValues": null,
"recipients": [
{
"id": 24997,
"name": "James Tschetter",
"role": "SIGNER",
"email": "patrick@imexsystems.ca",
"token": "uMom0GwL29NBqMfohGpUE",
"signedAt": "2026-02-27T22:11:52.835Z",
"expiresAt": "2026-05-28T22:10:48.991Z",
"documentId": 10929,
"readStatus": "OPENED",
"sendStatus": "SENT",
"templateId": null,
"authOptions": {
"accessAuth": [],
"actionAuth": []
},
"signingOrder": null,
"signingStatus": "SIGNED",
"rejectionReason": null,
"documentDeletedAt": null,
"expirationNotifiedAt": null
}
],
"templateId": null,
"visibility": "EVERYONE",
"authOptions": {
"globalAccessAuth": [],
"globalActionAuth": []
},
"completedAt": "2026-02-27T22:11:57.752Z",
"documentMeta": {
"id": "cmm5g3y7u00ecad1sv3ague1w",
"message": "CASL Message set in JSR",
"subject": "CASL Auth Set in JSR",
"language": "en",
"timezone": "Etc/UTC",
"dateFormat": "yyyy-MM-dd hh:mm a",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"emailSettings": {
"documentDeleted": true,
"documentPending": true,
"recipientSigned": true,
"recipientRemoved": true,
"documentCompleted": true,
"ownerDocumentCompleted": true,
"recipientSigningRequest": true
},
"distributionMethod": "EMAIL",
"drawSignatureEnabled": true,
"typedSignatureEnabled": true,
"allowDictateNextSigner": false,
"uploadSignatureEnabled": true
}
}
// const sampleBody = {
// event: "DOCUMENT_COMPLETED",
// payload: {
// Recipient: [
// {
// authOptions: {
// accessAuth: [
// ],
// actionAuth: [
// ],
// },
// documentDeletedAt: null,
// documentId: 9827,
// email: "patrick@imexsystems.ca",
// expired: null,
// id: 13311,
// name: "Customer Fullname",
// readStatus: "OPENED",
// rejectionReason: null,
// role: "SIGNER",
// sendStatus: "SENT",
// signedAt: "2026-01-30T18:29:12.648Z",
// signingOrder: null,
// signingStatus: "SIGNED",
// templateId: null,
// token: "uiEWIsXUPTbWHd7QedVgt",
// },
// ],
// authOptions: {
// globalAccessAuth: [
// ],
// globalActionAuth: [
// ],
// },
// completedAt: "2026-01-30T18:29:16.279Z",
// createdAt: "2026-01-30T18:28:48.861Z",
// deletedAt: null,
// documentMeta: {
// allowDictateNextSigner: false,
// dateFormat: "yyyy-MM-dd hh:mm a",
// distributionMethod: "EMAIL",
// drawSignatureEnabled: true,
// emailSettings: {
// documentCompleted: true,
// documentDeleted: true,
// documentPending: true,
// ownerDocumentCompleted: true,
// recipientRemoved: false,
// recipientSigned: true,
// recipientSigningRequest: true,
// },
// id: "cml17vfb200qjad1t2spxnc1n",
// language: "en",
// message: "To perform repairs on your vehicle, we must receive digital authorization. Please review and sign the document to proceed with repairs. ",
// redirectUrl: null,
// signingOrder: "PARALLEL",
// subject: "Repair Authorization for ABC Collision",
// timezone: "Etc/UTC",
// typedSignatureEnabled: true,
// uploadSignatureEnabled: true,
// },
// externalId: null,
// formValues: null,
// id: 9827,
// recipients: [
// {
// authOptions: {
// accessAuth: [
// ],
// actionAuth: [
// ],
// },
// documentDeletedAt: null,
// documentId: 9827,
// email: "patrick@imexsystems.ca",
// expired: null,
// id: 13311,
// name: "Customer Fullname",
// readStatus: "OPENED",
// rejectionReason: null,
// role: "SIGNER",
// sendStatus: "SENT",
// signedAt: "2026-01-30T18:29:12.648Z",
// signingOrder: null,
// signingStatus: "SIGNED",
// templateId: null,
// token: "uiEWIsXUPTbWHd7QedVgt",
// },
// ],
// source: "DOCUMENT",
// status: "COMPLETED",
// teamId: 742,
// templateId: null,
// title: "Repair Authorization - 1/30/2026, 6:28:48 PM",
// updatedAt: "2026-01-30T18:29:16.280Z",
// userId: 654,
// visibility: "EVERYONE",
// },
// createdAt: "2026-01-30T18:29:18.504Z",
// webhookEndpoint: "https://dev.patrickfic.com/esign/webhook",
// }
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;
}
`Unexpected Status or Content-Type: Status 200 Content-Type application/pdf\nBody: %PDF-1.7\n%<25><><EFBFBD><EFBFBD>\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n/Names 74 0 R\n/Dests 75 0 R\n/Info 77 0 R\n/Lang (en-US)\n/Version /1.7\n>>\nendobj\n77 0 obj\n<<\n/Type /Info\n/CreationDate (D:20260227230617Z00'00')\n/Producer <FEFF007000640066002D006C006900620020002800680074007400700073003A002F002F006700690074006800750062002E0063006F006D002F0048006F007000640069006E0067002F007000640066002D006C006900620029>\n/ModDate (D:20260227231057Z)…<>5[<5B>><3E>Wu7<><37>V<EFBFBD><56><EFBFBD><EFBFBD><EFBFBD>Pw<50>WX<57>ܮJ'6NWg<57>vYϳ<><CFB3><EFBFBD><EFBFBD><EFBFBD>Щr<D0A9>\n\t+<2B>1<EFBFBD><10>m{휑 <0C>hwb<><62><EFBFBD>8<EFBFBD><38>q y<>1e<31>)۱<>5m<35><6D><08><>MVM!<21>m<EFBFBD>[A<><41><10>{l<><6C>\t<EFBFBD>hia4<61><34>Tm<54><6D>8<><38>a<>e<EFBFBD>}<7D> ߫<><DFAB><15>]MVpяG<D18F><47>֏<EFBFBD>jJ<"<22>A<EFBFBD>mO*<2A>P<EFBFBD> <0B><><><7F><EFBFBD><EFBFBD>ѧЛ\nendstream\nendobj\n26 0 obj\n<<\n/Length 478/Filter /FlateDecode\n>>\nstream\nx<EFBFBD>MSK<EFBFBD>9<08><>)<29><>*<04>O<EFBFBD>i<EFBFBD><69>,<2C><>o <20><>kS%<25>$<EFBFBD><EFBFBD>hR\rS'<27>I<EFBFBD><49>~<7E><03><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>T[/<2F>{<05>k<EFBFBD>FC#<23><>֛<><D69B><EFBFBD>;Ӏ<>[<5B>⫀m<E2AB80>|Q<1F><>\x1b<EFBFBD><16>><3E> R<><52><EFBFBD><EFBFBD><EFBFBD>a<EFBFBD>E#<23>pI<70><49>._H<5F>ᆫt<E186AB>k<EFBFBD>D3p<33>I<EFBFBD><49><EFBFBD><EFBFBD><01>W2<57><32><EFBFBD>oJ0<4A>j<EFBFBD><6A><EFBFBD>j#<23><>!<21>$<EFBFBD><EFBFBD>-<2D><08><><EFBFBD><EFBFBD><EFBFBD><><CEAB><EFBFBD>TI|8D<38>H<1C><>Y<EFBFBD><59>x<EFBFBD><78><EFBFBD><EFBFBD>1<EFBFBD>73%<25>u<EFBFBD>T<EFBFBD><54>Ӑ.rcb<63>x<EFBFBD><78>Dd6=<3D><>Oڏ1 ^<5E>-<2D>...and 252354 more chars`

View File

@@ -0,0 +1,95 @@
export type WebhookEventType =
| "DOCUMENT_CREATED"
| "DOCUMENT_SENT"
| "DOCUMENT_COMPLETED"
| "DOCUMENT_REJECTED"
| "DOCUMENT_CANCELLED"
| "DOCUMENT_OPENED"
| "DOCUMENT_SIGNED";
export interface AuthOptions {
accessAuth: unknown[];
actionAuth: unknown[];
}
export interface Recipient {
id: number;
name: string;
role: string;
email: string;
token?: string | null;
signedAt?: string | null;
expiresAt?: string | null;
documentId?: number;
readStatus?: string | null;
sendStatus?: string | null;
templateId?: number | null;
authOptions?: AuthOptions;
signingOrder?: number | null;
signingStatus?: string | null;
rejectionReason?: string | null;
documentDeletedAt?: string | null;
expirationNotifiedAt?: string | null;
}
export interface EmailSettings {
documentDeleted: boolean;
documentPending: boolean;
recipientSigned: boolean;
recipientRemoved: boolean;
documentCompleted: boolean;
ownerDocumentCompleted: boolean;
recipientSigningRequest: boolean;
}
export interface DocumentMeta {
id: string;
message?: string | null;
subject?: string | null;
language?: string | null;
timezone?: string | null;
dateFormat?: string | null;
redirectUrl?: string | null;
signingOrder?: string | null;
emailSettings?: EmailSettings;
distributionMethod?: string | null;
drawSignatureEnabled?: boolean;
typedSignatureEnabled?: boolean;
allowDictateNextSigner?: boolean;
uploadSignatureEnabled?: boolean;
}
export interface DocumentAuthOptions {
globalAccessAuth: unknown[];
globalActionAuth: unknown[];
}
export interface DocumentPayload {
id: number;
title?: string | null;
source?: string | null;
status?: string | null;
teamId?: number | null;
userId?: number | null;
Recipient?: Recipient[];
recipients?: Recipient[];
createdAt?: string | null;
deletedAt?: string | null;
updatedAt?: string | null;
externalId?: string | null;
formValues?: unknown | null;
templateId?: number | null;
visibility?: string | null;
authOptions?: DocumentAuthOptions;
completedAt?: string | null;
documentMeta?: DocumentMeta | null;
}
export interface WebhookEventPayload {
event: WebhookEventType;
payload: DocumentPayload;
createdAt?: string | null;
webhookEndpoint?: string | null;
}
export default WebhookEventPayload;

View File

@@ -959,7 +959,7 @@ async function UpdateDmsVehicle({ socket, redisHelpers, JobData, DMSVeh, DMSCust
delete DMSVehToSend.inventoryAccount;
!DMSVehToSend.vehicle.engineNumber && delete DMSVehToSend.vehicle.engineNumber;
!DMSVehToSend.vehicle.saleClassValue && DMSVehToSend.vehicle.saleClassValue === "MISC";
!DMSVehToSend.vehicle.saleClassValue && (DMSVehToSend.vehicle.saleClassValue = "MISC");
!DMSVehToSend.vehicle.exteriorColor && delete DMSVehToSend.vehicle.exteriorColor;
const result = await MakeFortellisCall({

View File

@@ -1285,6 +1285,7 @@ exports.KAIZEN_QUERY = `query KAIZEN_EXPORT($start: timestamptz, $bodyshopid: uu
date_repairstarted
date_void
dms_allocation
ded_amt
employee_body_rel {
first_name
last_name
@@ -1380,6 +1381,7 @@ exports.KAIZEN_QUERY = `query KAIZEN_EXPORT($start: timestamptz, $bodyshopid: uu
}
parts_tax_rates
plate_no
policy_no
rate_la1
rate_la2
rate_la3
@@ -2251,18 +2253,16 @@ exports.UPDATE_OLD_TRANSITION = `mutation UPDATE_OLD_TRANSITION($jobid: uuid!, $
exports.INSERT_NEW_TRANSITION = (
includeOldTransition
) => `mutation INSERT_NEW_TRANSITION($newTransition: transitions_insert_input!, ${
includeOldTransition ? `$oldTransitionId: uuid!, $duration: numeric` : ""
) => `mutation INSERT_NEW_TRANSITION($newTransition: transitions_insert_input!, ${includeOldTransition ? `$oldTransitionId: uuid!, $duration: numeric` : ""
}) {
insert_transitions_one(object: $newTransition) {
id
}
${
includeOldTransition
? `update_transitions(where: {id: {_eq: $oldTransitionId}}, _set: {duration: $duration}) {
${includeOldTransition
? `update_transitions(where: {id: {_eq: $oldTransitionId}}, _set: {duration: $duration}) {
affected_rows
}`
: ""
: ""
}
}`;
@@ -2461,6 +2461,8 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
}
percentage
labor_rates
payout_method
commission_rates
}
}
}
@@ -2471,6 +2473,7 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
productivehrs
actualhrs
ciecacode
payout_context
}
lbr_adjustments
ro_number
@@ -2562,6 +2565,8 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
}
percentage
labor_rates
payout_method
commission_rates
}
}
}
@@ -2572,6 +2577,7 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
productivehrs
actualhrs
ciecacode
payout_context
}
lbr_adjustments
ro_number
@@ -3248,3 +3254,46 @@ exports.SET_JOB_DMS_ID = `mutation SetJobDmsId($id: uuid!, $dms_id: String!, $dm
kmin
}
}`;
exports.QUERY_JOB_FOR_SIGNATURE = `query QUERY_JOB_FOR_SIGNATURE($jobid: uuid!) {
jobs_by_pk(id: $jobid) {
id
ownr_fn
ownr_ln
ownr_co_nm
ownr_ea
ownr_ph1
}
}
`
exports.INSERT_ESIG_AUDIT_TRAIL = `mutation INSERT_ESIG_AUDIT_TRAIL($obj: audit_trail_insert_input!) {
insert_audit_trail_one(object: $obj) {
id
}
}
`
exports.QUERY_META_FOR_ESIG_COMPLETION = `query QUERY_META_FOR_ESIG_COMPLETION($jobid: uuid!) {
jobs_by_pk(id: $jobid) {
id
ro_number
bodyshop {
id
uselocalmediaserver
localmediatoken
localmediaserverhttp
localmediaservernetwork
}
}
}`
exports.INSERT_ESIGNATURE_DOCUMENT = `mutation INSERT_ESIGNATURE_DOCUMENT($docInput: documents_insert_input!) {
insert_documents_one(object: $docInput) {
id
name
key
}
}
`

View File

@@ -1,7 +1,7 @@
const sendPaymentNotificationEmail = require("./sendPaymentNotificationEmail");
const { INSERT_NEW_PAYMENT, GET_BODYSHOP_BY_ID, GET_JOBS_BY_PKS } = require("../../graphql-client/queries");
const getPaymentType = require("./getPaymentType");
const moment = require("moment");
const moment = require("moment-timezone");
const gqlClient = require("../../graphql-client/graphql-client").client;

View File

@@ -8,7 +8,7 @@ const {
const { sendTaskEmail } = require("../../email/sendemail");
const getPaymentType = require("./getPaymentType");
const moment = require("moment");
const moment = require("moment-timezone");
const gqlClient = require("../../graphql-client/graphql-client").client;

View File

@@ -1,5 +1,18 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const { mockSend } = vi.hoisted(() => ({
mockSend: vi.fn()
}));
vi.mock("@aws-sdk/client-secrets-manager", () => {
return {
SecretsManagerClient: vi.fn(() => ({
send: mockSend
})),
GetSecretValueCommand: vi.fn((input) => input)
};
});
const getPaymentType = require("../getPaymentType");
const decodeComment = require("../decodeComment");
const getCptellerUrl = require("../getCptellerUrl");
@@ -145,28 +158,15 @@ describe("Payment Processing Functions", () => {
// GetShopCredentials Tests
describe("getShopCredentials", () => {
const originalEnv = { ...process.env };
let mockSend;
beforeEach(() => {
mockSend = vi.fn();
vi.mock("@aws-sdk/client-secrets-manager", () => {
return {
SecretsManagerClient: vi.fn(() => ({
send: mockSend
})),
GetSecretValueCommand: vi.fn((input) => input)
};
});
mockSend.mockReset();
process.env.INTELLIPAY_MERCHANTKEY = "test-merchant-key";
process.env.INTELLIPAY_APIKEY = "test-api-key";
vi.resetModules();
});
afterEach(() => {
process.env = { ...originalEnv };
vi.restoreAllMocks();
vi.unmock("@aws-sdk/client-secrets-manager");
});
it("returns environment variables in non-production environment", async () => {

View File

@@ -315,7 +315,12 @@ function CalculateRatesTotals(ratesList) {
if (item.mod_lbr_ty) {
//Check to see if it has 0 hours and a price instead.
//Extend for when there are hours and a price.
if (item.lbr_op === "OP14" && item.act_price > 0 && (!item.part_type || item.mod_lb_hrs === 0) && !IsAdditionalCost(item)) {
if (
item.lbr_op === "OP14" &&
item.act_price > 0 &&
(!item.part_type || item.mod_lb_hrs === 0) &&
!IsAdditionalCost(item)
) {
//Scenario where SGI may pay out hours using a part price.
if (!ret[item.mod_lbr_ty.toLowerCase()].total) {
ret[item.mod_lbr_ty.toLowerCase()].total = Dinero();
@@ -339,38 +344,30 @@ function CalculateRatesTotals(ratesList) {
let subtotal = Dinero({ amount: 0 });
let rates_subtotal = Dinero({ amount: 0 });
for (const property in ret) {
for (const [property, values] of Object.entries(ret)) {
//Skip calculating mapa and mash if we got the amounts.
if (!((property === "mapa" && hasMapaLine) || (property === "mash" && hasMashLine))) {
if (!ret[property].total) {
ret[property].total = Dinero();
}
let threshold;
//Check if there is a max for this type.
if (ratesList.materials && ratesList.materials[property]) {
//
if (ratesList.materials[property].cal_maxdlr && ratesList.materials[property].cal_maxdlr > 0) {
//It has an upper threshhold.
threshold = Dinero({
amount: Math.round(ratesList.materials[property].cal_maxdlr * 100)
});
}
}
const shouldSkipCalculation = (property === "mapa" && hasMapaLine) || (property === "mash" && hasMashLine);
if (!shouldSkipCalculation) {
values.total ??= Dinero();
//Check if there is a max for this type and apply it.
const maxDollar =
ratesList.materials?.[property]?.cal_maxdlr || ratesList.materials?.[property.toUpperCase()]?.cal_maxdlr;
const threshold = maxDollar > 0 ? Dinero({ amount: Math.round(maxDollar * 100) }) : null;
const total = Dinero({
amount: Math.round((ret[property].rate || 0) * 100)
}).multiply(ret[property].hours);
amount: Math.round((values.rate || 0) * 100)
}).multiply(values.hours);
if (threshold && total.greaterThanOrEqual(threshold)) {
ret[property].total = ret[property].total.add(threshold);
} else {
ret[property].total = ret[property].total.add(total);
}
values.total = values.total.add(threshold && total.greaterThanOrEqual(threshold) ? threshold : total);
}
subtotal = subtotal.add(ret[property].total);
subtotal = subtotal.add(values.total);
if (property !== "mapa" && property !== "mash") rates_subtotal = rates_subtotal.add(ret[property].total);
if (property !== "mapa" && property !== "mash") {
rates_subtotal = rates_subtotal.add(values.total);
}
}
ret.subtotal = subtotal;

View File

@@ -35,6 +35,11 @@ describe("TotalsServerSide fixture tests", () => {
const fixtureFiles = fs.readdirSync(fixturesDir).filter((f) => f.endsWith(".json"));
if (fixtureFiles.length === 0) {
it.skip("skips when no job total fixtures are present", () => {});
return;
}
const dummyClient = {
request: async () => {
return {};

View File

@@ -94,6 +94,47 @@ const generateSignedUploadUrls = async (req, res) => {
}
};
/**
* Upload a file buffer directly to S3.
* Accepts either `req.file.buffer` (e.g. from multer) or `req.body.buffer` (base64 string).
*/
const uploadFileBuffer = async ({ key, contentType, buffer }) => {
try {
if (!key) {
throw new Error("key is required");
}
if (!buffer) {
throw new Error("buffer is required");
}
const isPdf = key.toLowerCase().endsWith(".pdf");
const client = new S3Client({ region: InstanceRegion() });
const putParams = {
Bucket: imgproxyDestinationBucket,
Key: key,
Body: buffer,
StorageClass: "INTELLIGENT_TIERING"
};
if (contentType) {
putParams.ContentType = contentType;
} else if (isPdf) {
putParams.ContentType = "application/pdf";
}
await client.send(new PutObjectCommand(putParams));
return ({ success: true, key, bucket: imgproxyDestinationBucket });
} catch (error) {
return { success: false, message: error.message, stack: error.stack };
}
};
/**
* Get Thumbnail URLS
* @param req
@@ -500,6 +541,7 @@ const keyStandardize = (doc) => {
module.exports = {
generateSignedUploadUrls,
uploadFileBuffer,
getThumbnailUrls,
getOriginalImageByDocumentId,
downloadFiles,

View File

@@ -1,20 +1,9 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
const logger = require("../utils/logger");
const { CalculateExpectedHoursForJob, CalculateTicketsHoursForJob } = require("./pay-all");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
const get = (obj, key) => {
return key.split(".").reduce((o, x) => {
return typeof o == "undefined" || o === null ? o : o[x];
}, obj);
};
exports.calculatelabor = async function (req, res) {
const { jobid, calculateOnly } = req.body;
const { jobid } = req.body;
logger.log("job-payroll-calculate-labor", "DEBUG", req.user.email, jobid, null);
const BearerToken = req.BearerToken;
@@ -41,23 +30,19 @@ exports.calculatelabor = async function (req, res) {
Object.keys(employeeHash).forEach((employeeIdKey) => {
//At the employee level.
Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => {
//At the labor level
Object.keys(employeeHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => {
//At the rate level.
const expectedHours = employeeHash[employeeIdKey][laborTypeKey][rateKey];
//Will the following line fail? Probably if it doesn't exist.
const claimedHours = get(ticketHash, `${employeeIdKey}.${laborTypeKey}.${rateKey}`);
if (claimedHours) {
delete ticketHash[employeeIdKey][laborTypeKey][rateKey];
}
const expected = employeeHash[employeeIdKey][laborTypeKey];
const claimed = ticketHash?.[employeeIdKey]?.[laborTypeKey];
totals.push({
employeeid: employeeIdKey,
rate: rateKey,
mod_lbr_ty: laborTypeKey,
expectedHours,
claimedHours: claimedHours || 0
});
if (claimed) {
delete ticketHash[employeeIdKey][laborTypeKey];
}
totals.push({
employeeid: employeeIdKey,
rate: expected.rate,
mod_lbr_ty: laborTypeKey,
expectedHours: expected.hours,
claimedHours: claimed?.hours || 0
});
});
});
@@ -65,23 +50,14 @@ exports.calculatelabor = async function (req, res) {
Object.keys(ticketHash).forEach((employeeIdKey) => {
//At the employee level.
Object.keys(ticketHash[employeeIdKey]).forEach((laborTypeKey) => {
//At the labor level
Object.keys(ticketHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => {
//At the rate level.
const expectedHours = 0;
//Will the following line fail? Probably if it doesn't exist.
const claimedHours = get(ticketHash, `${employeeIdKey}.${laborTypeKey}.${rateKey}`);
if (claimedHours) {
delete ticketHash[employeeIdKey][laborTypeKey][rateKey];
}
const claimed = ticketHash[employeeIdKey][laborTypeKey];
totals.push({
employeeid: employeeIdKey,
rate: rateKey,
mod_lbr_ty: laborTypeKey,
expectedHours,
claimedHours: claimedHours || 0
});
totals.push({
employeeid: employeeIdKey,
rate: claimed.rate,
mod_lbr_ty: laborTypeKey,
expectedHours: 0,
claimedHours: claimed.hours || 0
});
});
});
@@ -101,6 +77,6 @@ exports.calculatelabor = async function (req, res) {
jobid: jobid,
error
});
res.status(503).send();
res.status(400).json({ error: error.message });
}
};

View File

@@ -1,11 +1,42 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
const logger = require("../utils/logger");
const { CalculateExpectedHoursForJob } = require("./pay-all");
const { CalculateExpectedHoursForJob, RoundPayrollHours } = require("./pay-all");
const moment = require("moment");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
const normalizePercent = (value) => Math.round((Number(value || 0) + Number.EPSILON) * 10000) / 10000;
const getTaskPresetAllocationError = (taskPresets = []) => {
const totalsByLaborType = {};
taskPresets.forEach((taskPreset) => {
const percent = normalizePercent(taskPreset?.percent);
if (!percent) {
return;
}
const laborTypes = Array.isArray(taskPreset?.hourstype) ? taskPreset.hourstype : [];
laborTypes.forEach((laborType) => {
if (!laborType) {
return;
}
totalsByLaborType[laborType] = normalizePercent((totalsByLaborType[laborType] || 0) + percent);
});
});
const overAllocatedType = Object.entries(totalsByLaborType).find(([, total]) => total > 100);
if (!overAllocatedType) {
return null;
}
const [laborType, total] = overAllocatedType;
return `Task preset percentages for labor type ${laborType} total ${total}% and cannot exceed 100%.`;
};
exports.GetTaskPresetAllocationError = getTaskPresetAllocationError;
exports.claimtask = async function (req, res) {
const { jobid, task, calculateOnly, employee } = req.body;
@@ -21,12 +52,25 @@ exports.claimtask = async function (req, res) {
id: jobid
});
const theTaskPreset = job.bodyshop.md_tasks_presets.presets.find((tp) => tp.name === task);
const taskPresets = job.bodyshop?.md_tasks_presets?.presets || [];
const taskPresetAllocationError = getTaskPresetAllocationError(taskPresets);
if (taskPresetAllocationError) {
res.status(400).json({ success: false, error: taskPresetAllocationError });
return;
}
const theTaskPreset = taskPresets.find((tp) => tp.name === task);
if (!theTaskPreset) {
res.status(400).json({ success: false, error: "Provided task preset not found." });
return;
}
const taskAlreadyCompleted = (job.completed_tasks || []).some((completedTask) => completedTask?.name === task);
if (taskAlreadyCompleted) {
res.status(400).json({ success: false, error: "Provided task preset has already been completed for this job." });
return;
}
//Get all of the assignments that are filtered.
const { assignmentHash, employeeHash } = CalculateExpectedHoursForJob(job, theTaskPreset.hourstype);
const ticketsToInsert = [];
@@ -35,32 +79,37 @@ exports.claimtask = async function (req, res) {
Object.keys(employeeHash).forEach((employeeIdKey) => {
//At the employee level.
Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => {
//At the labor level
Object.keys(employeeHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => {
//At the rate level.
const expectedHours = employeeHash[employeeIdKey][laborTypeKey][rateKey] * (theTaskPreset.percent / 100);
const expected = employeeHash[employeeIdKey][laborTypeKey];
const expectedHours = RoundPayrollHours(expected.hours * (theTaskPreset.percent / 100));
ticketsToInsert.push({
task_name: task,
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: employeeIdKey,
productivehrs: expectedHours,
rate: rateKey,
ciecacode: laborTypeKey,
flat_rate: true,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborTypeKey],
memo: `*Flagged Task* ${theTaskPreset.memo}`
});
ticketsToInsert.push({
task_name: task,
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: employeeIdKey,
productivehrs: expectedHours,
rate: expected.rate,
ciecacode: laborTypeKey,
flat_rate: true,
created_by: employee?.name || req.user.email,
payout_context: {
...(expected.payoutContext || {}),
generated_by: req.user.email,
generated_at: new Date().toISOString(),
generated_from: "claimtask",
task_name: task
},
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborTypeKey],
memo: `*Flagged Task* ${theTaskPreset.memo}`
});
});
});
if (!calculateOnly) {
//Insert the time ticekts if we're not just calculating them.
const insertResult = await client.request(queries.INSERT_TIME_TICKETS, {
await client.request(queries.INSERT_TIME_TICKETS, {
timetickets: ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0)
});
const updateResult = await client.request(queries.UPDATE_JOB, {
await client.request(queries.UPDATE_JOB, {
jobId: job.id,
job: {
status: theTaskPreset.nextstatus,
@@ -82,6 +131,6 @@ exports.claimtask = async function (req, res) {
jobid: jobid,
error
});
res.status(503).send();
res.status(400).json({ success: false, error: error.message });
}
};

View File

@@ -1,15 +1,196 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
const rdiff = require("recursive-diff");
const logger = require("../utils/logger");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
Dinero.globalFormatRoundingMode = "HALF_EVEN";
const PAYOUT_METHODS = {
hourly: "hourly",
commission: "commission"
};
const CURRENCY_PRECISION = 2;
const HOURS_PRECISION = 5;
const toNumber = (value) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
};
const normalizeNumericString = (value) => {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" && Number.isFinite(value)) {
const asString = value.toString();
if (!asString.toLowerCase().includes("e")) {
return asString;
}
return value.toFixed(12).replace(/0+$/, "").replace(/\.$/, "");
}
return `${value ?? ""}`.trim();
};
const decimalToDinero = (value, errorMessage = "Invalid numeric value.") => {
const normalizedValue = normalizeNumericString(value);
const parsedValue = Number(normalizedValue);
if (!Number.isFinite(parsedValue)) {
throw new Error(errorMessage);
}
const isNegative = normalizedValue.startsWith("-");
const unsignedValue = normalizedValue.replace(/^[+-]/, "");
const [wholePart = "0", fractionPartRaw = ""] = unsignedValue.split(".");
const wholeDigits = wholePart.replace(/\D/g, "") || "0";
const fractionDigits = fractionPartRaw.replace(/\D/g, "");
const amount = Number(`${wholeDigits}${fractionDigits}` || "0") * (isNegative ? -1 : 1);
return Dinero({
amount,
precision: fractionDigits.length
});
};
const roundValueWithDinero = (value, precision, errorMessage) =>
decimalToDinero(value, errorMessage).convertPrecision(precision, Dinero.globalRoundingMode).toUnit();
const roundCurrency = (value, errorMessage = "Invalid currency value.") =>
roundValueWithDinero(value, CURRENCY_PRECISION, errorMessage);
const roundHours = (value, errorMessage = "Invalid hours value.") => roundValueWithDinero(value, HOURS_PRECISION, errorMessage);
const normalizePayoutMethod = (value) =>
value === PAYOUT_METHODS.commission ? PAYOUT_METHODS.commission : PAYOUT_METHODS.hourly;
const hasOwnValue = (obj, key) => Object.prototype.hasOwnProperty.call(obj || {}, key);
const getJobSaleRateField = (laborType) => `rate_${String(laborType || "").toLowerCase()}`;
const getTeamMemberLabel = (teamMember) => {
const fullName = `${teamMember?.employee?.first_name || ""} ${teamMember?.employee?.last_name || ""}`.trim();
return fullName || teamMember?.employee?.id || teamMember?.employeeid || "unknown employee";
};
const parseRequiredNumber = (value, errorMessage) => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new Error(errorMessage);
}
return parsed;
};
const buildFallbackPayoutContext = ({ laborType, rate }) => ({
payout_type: "legacy",
payout_method: "legacy",
cut_percent_applied: null,
source_labor_rate: null,
source_labor_type: laborType,
effective_rate: roundCurrency(rate)
});
function BuildPayoutDetails(job, teamMember, laborType) {
const payoutMethod = normalizePayoutMethod(teamMember?.payout_method);
const teamMemberLabel = getTeamMemberLabel(teamMember);
const sourceLaborRateField = getJobSaleRateField(laborType);
if (payoutMethod === PAYOUT_METHODS.hourly && !hasOwnValue(teamMember?.labor_rates, laborType)) {
throw new Error(`Missing hourly payout rate for ${teamMemberLabel} on labor type ${laborType}.`);
}
if (payoutMethod === PAYOUT_METHODS.commission && !hasOwnValue(teamMember?.commission_rates, laborType)) {
throw new Error(`Missing commission percent for ${teamMemberLabel} on labor type ${laborType}.`);
}
if (payoutMethod === PAYOUT_METHODS.commission && !hasOwnValue(job, sourceLaborRateField)) {
throw new Error(`Missing sale rate ${sourceLaborRateField} for labor type ${laborType}.`);
}
const hourlyRate =
payoutMethod === PAYOUT_METHODS.hourly
? roundCurrency(
parseRequiredNumber(
teamMember?.labor_rates?.[laborType],
`Invalid hourly payout rate for ${teamMemberLabel} on labor type ${laborType}.`
)
)
: null;
const commissionPercent =
payoutMethod === PAYOUT_METHODS.commission
? roundCurrency(
parseRequiredNumber(
teamMember?.commission_rates?.[laborType],
`Invalid commission percent for ${teamMemberLabel} on labor type ${laborType}.`
)
)
: null;
if (commissionPercent !== null && (commissionPercent < 0 || commissionPercent > 100)) {
throw new Error(`Commission percent for ${teamMemberLabel} on labor type ${laborType} must be between 0 and 100.`);
}
const sourceLaborRate =
payoutMethod === PAYOUT_METHODS.commission
? roundCurrency(
parseRequiredNumber(job?.[sourceLaborRateField], `Invalid sale rate ${sourceLaborRateField} for labor type ${laborType}.`)
)
: null;
const effectiveRate =
payoutMethod === PAYOUT_METHODS.commission
? roundCurrency((sourceLaborRate * toNumber(commissionPercent)) / 100)
: hourlyRate;
return {
effectiveRate,
payoutContext: {
payout_type: payoutMethod === PAYOUT_METHODS.commission ? "cut" : "hourly",
payout_method: payoutMethod,
cut_percent_applied: commissionPercent,
source_labor_rate: sourceLaborRate,
source_labor_type: laborType,
effective_rate: effectiveRate
}
};
}
function BuildGeneratedPayoutContext({ baseContext, generatedBy, generatedFrom, taskName, usedTicketFallback }) {
return {
...(baseContext || {}),
generated_by: generatedBy,
generated_at: new Date().toISOString(),
generated_from: generatedFrom,
task_name: taskName,
used_ticket_fallback: Boolean(usedTicketFallback)
};
}
function getAllKeys(...objects) {
return [...new Set(objects.flatMap((obj) => (obj ? Object.keys(obj) : [])))];
}
function buildPayAllMemo({ deltaHours, hasExpected, hasClaimed, userEmail }) {
if (!hasClaimed && deltaHours > 0) {
return `Add unflagged hours. (${userEmail})`;
}
if (!hasExpected && deltaHours < 0) {
return `Remove flagged hours per assignment. (${userEmail})`;
}
return `Adjust flagged hours per assignment. (${userEmail})`;
}
exports.payall = async function (req, res) {
const { jobid, calculateOnly } = req.body;
const { jobid } = req.body;
logger.log("job-payroll-pay-all", "DEBUG", req.user.email, jobid, null);
const BearerToken = req.BearerToken;
@@ -22,253 +203,183 @@ exports.payall = async function (req, res) {
id: jobid
});
//iterate over each ticket, building a hash of team -> employee to calculate total assigned hours.
const { employeeHash, assignmentHash } = CalculateExpectedHoursForJob(job);
const ticketHash = CalculateTicketsHoursForJob(job);
if (assignmentHash.unassigned > 0) {
res.json({ success: false, error: "Not all hours have been assigned." });
return;
}
//Calculate how much time each tech should have by labor type.
//Doing this order creates a diff of changes on the ticket hash to make it the same as the employee hash.
const recursiveDiff = rdiff.getDiff(ticketHash, employeeHash, true);
const ticketsToInsert = [];
const employeeIds = getAllKeys(employeeHash, ticketHash);
recursiveDiff.forEach((diff) => {
//Every iteration is what we would need to insert into the time ticket hash
//so that it would match the employee hash exactly.
const path = diffParser(diff);
employeeIds.forEach((employeeId) => {
const expectedByLabor = employeeHash[employeeId] || {};
const claimedByLabor = ticketHash[employeeId] || {};
if (diff.op === "add") {
// console.log(Object.keys(diff.val));
if (typeof diff.val === "object" && Object.keys(diff.val).length > 1) {
//Multiple values to add.
Object.keys(diff.val).forEach((key) => {
// console.log("Hours", diff.val[key][Object.keys(diff.val[key])[0]]);
// console.log("Rate", Object.keys(diff.val[key])[0]);
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs: diff.val[key][Object.keys(diff.val[key])[0]],
rate: Object.keys(diff.val[key])[0],
ciecacode: key,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[key],
flat_rate: true,
memo: `Add unflagged hours. (${req.user.email})`
});
});
} else {
//Only the 1 value to add.
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs: path.hours,
rate: path.rate,
ciecacode: path.mod_lbr_ty,
flat_rate: true,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty],
memo: `Add unflagged hours. (${req.user.email})`
});
getAllKeys(expectedByLabor, claimedByLabor).forEach((laborType) => {
const expected = expectedByLabor[laborType];
const claimed = claimedByLabor[laborType];
const deltaHours = roundHours((expected?.hours || 0) - (claimed?.hours || 0));
if (deltaHours === 0) {
return;
}
} else if (diff.op === "update") {
//An old ticket amount isn't sufficient
//We can't modify the existing ticket, it might already be committed. So let's add a new one instead.
const effectiveRate = roundCurrency(expected?.rate ?? claimed?.rate);
const payoutContext = BuildGeneratedPayoutContext({
baseContext:
expected?.payoutContext ||
claimed?.payoutContext ||
buildFallbackPayoutContext({ laborType, rate: effectiveRate }),
generatedBy: req.user.email,
generatedFrom: "payall",
taskName: "Pay All",
usedTicketFallback: !expected && Boolean(claimed)
});
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs: diff.val - diff.oldVal,
rate: path.rate,
ciecacode: path.mod_lbr_ty,
employeeid: employeeId,
productivehrs: deltaHours,
rate: effectiveRate,
ciecacode: laborType,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborType],
flat_rate: true,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty],
memo: `Adjust flagged hours per assignment. (${req.user.email})`
created_by: req.user.email,
payout_context: payoutContext,
memo: buildPayAllMemo({
deltaHours,
hasExpected: Boolean(expected),
hasClaimed: Boolean(claimed),
userEmail: req.user.email
})
});
} else {
//Has to be a delete
if (typeof diff.oldVal === "object" && Object.keys(diff.oldVal).length > 1) {
//Multiple oldValues to add.
Object.keys(diff.oldVal).forEach((key) => {
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs: diff.oldVal[key][Object.keys(diff.oldVal[key])[0]] * -1,
rate: Object.keys(diff.oldVal[key])[0],
ciecacode: key,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[key],
flat_rate: true,
memo: `Remove flagged hours per assignment. (${req.user.email})`
});
});
} else {
//Only the 1 value to add.
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs: path.hours * -1,
rate: path.rate,
ciecacode: path.mod_lbr_ty,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty],
flat_rate: true,
memo: `Remove flagged hours per assignment. (${req.user.email})`
});
}
}
});
});
const insertResult = await client.request(queries.INSERT_TIME_TICKETS, {
timetickets: ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0)
});
const filteredTickets = ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0);
res.json(ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0));
if (filteredTickets.length > 0) {
await client.request(queries.INSERT_TIME_TICKETS, {
timetickets: filteredTickets
});
}
res.json(filteredTickets);
} catch (error) {
logger.log("job-payroll-labor-totals-error", "ERROR", req.user.email, jobid, {
jobid: jobid,
jobid,
error: JSON.stringify(error)
});
res.status(400).json({ error: error.message });
}
};
function diffParser(diff) {
const type = typeof diff.oldVal;
let mod_lbr_ty, rate, hours;
if (diff.path.length === 1) {
if (diff.op === "add") {
mod_lbr_ty = Object.keys(diff.val)[0];
rate = Object.keys(diff.val[mod_lbr_ty])[0];
// hours = diff.oldVal[mod_lbr_ty][rate];
} else {
mod_lbr_ty = Object.keys(diff.oldVal)[0];
rate = Object.keys(diff.oldVal[mod_lbr_ty])[0];
// hours = diff.oldVal[mod_lbr_ty][rate];
}
} else if (diff.path.length === 2) {
mod_lbr_ty = diff.path[1];
if (diff.op === "add") {
rate = Object.keys(diff.val)[0];
} else {
rate = Object.keys(diff.oldVal)[0];
}
} else if (diff.path.length === 3) {
mod_lbr_ty = diff.path[1];
rate = diff.path[2];
//hours = 0;
}
//Set the hours
if (typeof diff.val === "number" && diff.val !== null && diff.val !== undefined) {
hours = diff.val;
} else if (diff.val !== null && diff.val !== undefined) {
if (diff.path.length === 1) {
hours = diff.val[Object.keys(diff.val)[0]][Object.keys(diff.val[Object.keys(diff.val)[0]])];
} else {
hours = diff.val[Object.keys(diff.val)[0]];
}
} else if (typeof diff.oldVal === "number" && diff.oldVal !== null && diff.oldVal !== undefined) {
hours = diff.oldVal;
} else {
hours = diff.oldVal[Object.keys(diff.oldVal)[0]];
}
const ret = {
multiVal: false,
employeeid: diff.path[0], // Always True
mod_lbr_ty,
rate,
hours
};
return ret;
}
function CalculateExpectedHoursForJob(job, filterToLbrTypes) {
const assignmentHash = { unassigned: 0 };
const employeeHash = {}; // employeeid => Cieca labor type => rate => hours. Contains how many hours each person should be paid.
const employeeHash = {}; // employeeid => Cieca labor type => { hours, rate, payoutContext }
const laborTypeFilter = Array.isArray(filterToLbrTypes) ? filterToLbrTypes : null;
job.joblines
.filter((jobline) => {
if (!filterToLbrTypes) return true;
else {
return (
filterToLbrTypes.includes(jobline.mod_lbr_ty) ||
(jobline.convertedtolbr && filterToLbrTypes.includes(jobline.convertedtolbr_data.mod_lbr_ty))
);
if (!laborTypeFilter) {
return true;
}
const convertedLaborType = jobline.convertedtolbr ? jobline.convertedtolbr_data?.mod_lbr_ty : null;
return laborTypeFilter.includes(jobline.mod_lbr_ty) || (convertedLaborType && laborTypeFilter.includes(convertedLaborType));
})
.forEach((jobline) => {
if (jobline.convertedtolbr) {
// Line has been converte to labor. Temporarily re-assign the hours.
jobline.mod_lbr_ty = jobline.convertedtolbr_data.mod_lbr_ty;
jobline.mod_lb_hrs += jobline.convertedtolbr_data.mod_lb_hrs;
const laborType = jobline.convertedtolbr ? jobline.convertedtolbr_data?.mod_lbr_ty || jobline.mod_lbr_ty : jobline.mod_lbr_ty;
const laborHours = roundHours(
toNumber(jobline.mod_lb_hrs) + (jobline.convertedtolbr ? toNumber(jobline.convertedtolbr_data?.mod_lb_hrs) : 0)
);
if (laborHours === 0) {
return;
}
if (jobline.mod_lb_hrs != 0) {
//Check if the line is assigned. If not, keep track of it as an unassigned line by type.
if (jobline.assigned_team === null) {
assignmentHash.unassigned = assignmentHash.unassigned + jobline.mod_lb_hrs;
} else {
//Line is assigned.
if (!assignmentHash[jobline.assigned_team]) {
assignmentHash[jobline.assigned_team] = 0;
}
assignmentHash[jobline.assigned_team] = assignmentHash[jobline.assigned_team] + jobline.mod_lb_hrs;
//Create the assignment breakdown.
const theTeam = job.bodyshop.employee_teams.find((team) => team.id === jobline.assigned_team);
if (jobline.assigned_team === null) {
assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours);
return;
}
theTeam.employee_team_members.forEach((tm) => {
//Figure out how many hours they are owed at this line, and at what rate.
const theTeam = job.bodyshop.employee_teams.find((team) => team.id === jobline.assigned_team);
if (!employeeHash[tm.employee.id]) {
employeeHash[tm.employee.id] = {};
}
if (!employeeHash[tm.employee.id][jobline.mod_lbr_ty]) {
employeeHash[tm.employee.id][jobline.mod_lbr_ty] = {};
}
if (!employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]]) {
employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]] = 0;
}
if (!theTeam) {
assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours);
return;
}
const hoursOwed = (tm.percentage * jobline.mod_lb_hrs) / 100;
employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]] =
employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]] + hoursOwed;
});
assignmentHash[jobline.assigned_team] = roundHours((assignmentHash[jobline.assigned_team] || 0) + laborHours);
theTeam.employee_team_members.forEach((teamMember) => {
const employeeId = teamMember.employee.id;
const { effectiveRate, payoutContext } = BuildPayoutDetails(job, teamMember, laborType);
if (!employeeHash[employeeId]) {
employeeHash[employeeId] = {};
}
}
if (!employeeHash[employeeId][laborType]) {
employeeHash[employeeId][laborType] = {
hours: 0,
rate: effectiveRate,
payoutContext
};
}
const hoursOwed = roundHours((toNumber(teamMember.percentage) * laborHours) / 100);
employeeHash[employeeId][laborType].hours = roundHours(employeeHash[employeeId][laborType].hours + hoursOwed);
employeeHash[employeeId][laborType].rate = effectiveRate;
employeeHash[employeeId][laborType].payoutContext = payoutContext;
});
});
return { assignmentHash, employeeHash };
}
function CalculateTicketsHoursForJob(job) {
const ticketHash = {}; // employeeid => Cieca labor type => rate => hours.
//Calculate how much each employee has been paid so far.
const ticketHash = {}; // employeeid => Cieca labor type => { hours, rate, payoutContext }
job.timetickets.forEach((ticket) => {
if (!ticket?.employeeid || !ticket?.ciecacode) {
return;
}
if (!ticketHash[ticket.employeeid]) {
ticketHash[ticket.employeeid] = {};
}
if (!ticketHash[ticket.employeeid][ticket.ciecacode]) {
ticketHash[ticket.employeeid][ticket.ciecacode] = {};
ticketHash[ticket.employeeid][ticket.ciecacode] = {
hours: 0,
rate: roundCurrency(ticket.rate),
payoutContext: ticket.payout_context || null
};
}
if (!ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate]) {
ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] = 0;
ticketHash[ticket.employeeid][ticket.ciecacode].hours = roundHours(
ticketHash[ticket.employeeid][ticket.ciecacode].hours + toNumber(ticket.productivehrs)
);
if (ticket.rate !== null && ticket.rate !== undefined) {
ticketHash[ticket.employeeid][ticket.ciecacode].rate = roundCurrency(ticket.rate);
}
if (ticket.payout_context) {
ticketHash[ticket.employeeid][ticket.ciecacode].payoutContext = ticket.payout_context;
}
ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] =
ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] + ticket.productivehrs;
});
return ticketHash;
}
exports.BuildPayoutDetails = BuildPayoutDetails;
exports.CalculateExpectedHoursForJob = CalculateExpectedHoursForJob;
exports.CalculateTicketsHoursForJob = CalculateTicketsHoursForJob;
exports.RoundPayrollHours = roundHours;

View File

@@ -0,0 +1,367 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import mockRequire from "mock-require";
const logMock = vi.fn();
let payAllModule;
let claimTaskModule;
const buildBaseJob = (overrides = {}) => ({
id: "job-1",
completed_tasks: [],
rate_laa: 100,
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body"
}
}
},
md_tasks_presets: {
presets: []
},
employee_teams: []
},
joblines: [],
timetickets: [],
...overrides
});
const buildReqRes = ({ job, body = {}, userEmail = "payroll@example.com" }) => {
const client = {
setHeaders: vi.fn().mockReturnThis(),
request: vi.fn().mockResolvedValueOnce({ jobs_by_pk: job })
};
const req = {
body: {
jobid: job.id,
...body
},
user: {
email: userEmail
},
BearerToken: "Bearer test",
userGraphQLClient: client
};
const res = {
json: vi.fn(),
status: vi.fn().mockReturnThis()
};
return { client, req, res };
};
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mockRequire.stopAll();
mockRequire("../utils/logger", { log: logMock });
payAllModule = require("./pay-all");
claimTaskModule = require("./claim-task");
});
describe("payroll payout helpers", () => {
it("defaults team members to hourly payout when no payout method is stored", () => {
const { effectiveRate, payoutContext } = payAllModule.BuildPayoutDetails(
{},
{
labor_rates: {
LAA: 27.5
},
employee: {
id: "emp-1"
}
},
"LAA"
);
expect(effectiveRate).toBe(27.5);
expect(payoutContext).toEqual(
expect.objectContaining({
payout_type: "hourly",
payout_method: "hourly",
cut_percent_applied: null,
source_labor_rate: null,
source_labor_type: "LAA",
effective_rate: 27.5
})
);
});
it("calculates commission payout rates from the raw job labor sale rate", () => {
const { effectiveRate, payoutContext } = payAllModule.BuildPayoutDetails(
{
rate_laa: 120
},
{
payout_method: "commission",
commission_rates: {
LAA: 35
},
employee: {
id: "emp-1"
}
},
"LAA"
);
expect(effectiveRate).toBe(42);
expect(payoutContext).toEqual(
expect.objectContaining({
payout_type: "cut",
payout_method: "commission",
cut_percent_applied: 35,
source_labor_rate: 120,
source_labor_type: "LAA",
effective_rate: 42
})
);
});
it("uses Dinero half-even rounding for stored hourly rates", () => {
const { effectiveRate, payoutContext } = payAllModule.BuildPayoutDetails(
{},
{
labor_rates: {
LAA: 10.005
},
employee: {
id: "emp-1"
}
},
"LAA"
);
expect(effectiveRate).toBe(10);
expect(payoutContext.effective_rate).toBe(10);
});
it("throws a useful error when commission configuration is incomplete", () => {
expect(() =>
payAllModule.BuildPayoutDetails(
{
rate_laa: 100
},
{
payout_method: "commission",
commission_rates: {},
employee: {
first_name: "Jane",
last_name: "Doe"
}
},
"LAA"
)
).toThrow("Missing commission percent for Jane Doe on labor type LAA.");
});
});
describe("payroll routes", () => {
it("aggregates claimed hours across prior ticket rates and inserts the remaining delta at the current rate", async () => {
const job = buildBaseJob({
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body"
}
}
},
md_tasks_presets: {
presets: []
},
employee_teams: [
{
id: "team-1",
employee_team_members: [
{
percentage: 100,
payout_method: "commission",
commission_rates: {
LAA: 40
},
labor_rates: {
LAA: 30
},
employee: {
id: "emp-1",
first_name: "Jane",
last_name: "Doe"
}
}
]
}
]
},
joblines: [
{
mod_lbr_ty: "LAA",
mod_lb_hrs: 10,
assigned_team: "team-1",
convertedtolbr: false
}
],
timetickets: [
{
employeeid: "emp-1",
ciecacode: "LAA",
productivehrs: 2,
rate: 30,
payout_context: {
payout_method: "hourly"
}
},
{
employeeid: "emp-1",
ciecacode: "LAA",
productivehrs: 3,
rate: 35,
payout_context: {
payout_method: "commission"
}
}
]
});
const { client, req, res } = buildReqRes({ job });
client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 1 } });
await payAllModule.payall(req, res);
expect(client.request).toHaveBeenCalledTimes(2);
const insertedTickets = client.request.mock.calls[1][1].timetickets;
expect(insertedTickets).toHaveLength(1);
expect(insertedTickets[0]).toEqual(
expect.objectContaining({
task_name: "Pay All",
employeeid: "emp-1",
productivehrs: 5,
rate: 40,
ciecacode: "LAA",
cost_center: "Body",
created_by: "payroll@example.com"
})
);
expect(insertedTickets[0].payout_context).toEqual(
expect.objectContaining({
payout_method: "commission",
cut_percent_applied: 40,
source_labor_rate: 100,
generated_from: "payall",
task_name: "Pay All",
used_ticket_fallback: false
})
);
expect(res.json).toHaveBeenCalledWith(insertedTickets);
});
it("rejects duplicate claim-task submissions for completed presets", async () => {
const job = buildBaseJob({
completed_tasks: [{ name: "Disassembly" }],
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body"
}
}
},
md_tasks_presets: {
presets: [
{
name: "Disassembly",
hourstype: ["LAA"],
percent: 50,
nextstatus: "In Progress",
memo: "Flag disassembly"
}
]
},
employee_teams: []
}
});
const { client, req, res } = buildReqRes({
job,
body: {
task: "Disassembly",
calculateOnly: false,
employee: {
name: "Jane Doe",
employeeid: "emp-1"
}
}
});
await claimTaskModule.claimtask(req, res);
expect(client.request).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Provided task preset has already been completed for this job."
});
});
it("rejects claim-task when task presets over-allocate the same labor type", async () => {
const job = buildBaseJob({
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body"
}
}
},
md_tasks_presets: {
presets: [
{
name: "Body Prep",
hourstype: ["LAA"],
percent: 60,
nextstatus: "Prep",
memo: "Prep body work"
},
{
name: "Body Prime",
hourstype: ["LAA"],
percent: 50,
nextstatus: "Prime",
memo: "Prime body work"
}
]
},
employee_teams: []
}
});
const { client, req, res } = buildReqRes({
job,
body: {
task: "Body Prep",
calculateOnly: true,
employee: {
name: "Jane Doe",
employeeid: "emp-1"
}
}
});
await claimTaskModule.claimtask(req, res);
expect(client.request).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Task preset percentages for labor type LAA total 110% and cannot exceed 100%."
});
});
});

View File

@@ -0,0 +1,17 @@
const express = require("express");
const router = express.Router();
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware");
const { newEsignDocument, distributeDocument, deleteDocument } = require("../esign/esign-new");
const { esignWebhook } = require("../esign/webhook");
//router.use(validateFirebaseIdTokenMiddleware);
router.post("/new", validateFirebaseIdTokenMiddleware, withUserGraphQLClientMiddleware, newEsignDocument);
router.post("/distribute", validateFirebaseIdTokenMiddleware, withUserGraphQLClientMiddleware, distributeDocument);
router.post("/delete", validateFirebaseIdTokenMiddleware, withUserGraphQLClientMiddleware, deleteDocument);
router.post("/webhook", esignWebhook);
module.exports = router;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,7 @@
const { RRClient } = require("./lib/index.cjs");
const { getRRConfigFromBodyshop } = require("./rr-config");
const CreateRRLogEvent = require("./rr-logger-event");
const { withRRRequestXml } = require("./rr-log-xml");
const InstanceManager = require("../utils/instanceMgr").default;
/**
@@ -217,14 +218,24 @@ const createRRCustomer = async ({ bodyshop, job, overrides = {}, socket }) => {
try {
response = await client.insertCustomer(safePayload, opts);
// Very noisy; only show when log level is cranked to SILLY
CreateRRLogEvent(socket, "SILLY", "{CU} insertCustomer: raw response", { response });
CreateRRLogEvent(
socket,
"SILLY",
"{CU} insertCustomer: raw response",
withRRRequestXml(response, { response })
);
} catch (e) {
CreateRRLogEvent(socket, "ERROR", "RR insertCustomer transport error", {
message: e?.message,
code: e?.code,
status: e?.meta?.status || e?.status,
payload: safePayload
});
CreateRRLogEvent(
socket,
"ERROR",
"RR insertCustomer transport error",
withRRRequestXml(e, {
message: e?.message,
code: e?.code,
status: e?.meta?.status || e?.status,
payload: safePayload
})
);
throw e;
}
@@ -233,12 +244,17 @@ const createRRCustomer = async ({ bodyshop, job, overrides = {}, socket }) => {
let customerNo = data?.dmsRecKey;
if (!customerNo) {
CreateRRLogEvent(socket, "ERROR", "RR insertCustomer returned no dmsRecKey/custNo", {
status: trx?.status,
statusCode: trx?.statusCode,
message: trx?.message,
data
});
CreateRRLogEvent(
socket,
"ERROR",
"RR insertCustomer returned no dmsRecKey/custNo",
withRRRequestXml(response, {
status: trx?.status,
statusCode: trx?.statusCode,
message: trx?.message,
data
})
);
throw new Error(
`RR insertCustomer returned no dmsRecKey (status=${trx?.status ?? "?"} code=${trx?.statusCode ?? "?"}${

View File

@@ -4,7 +4,7 @@
* @returns {number|null}
*/
const parseVendorStatusCode = (err) => {
// Prefer explicit numeric props when available
// Prefer explicit numeric props when available.
const codeProp = err?.code ?? err?.statusCode ?? err?.meta?.status?.StatusCode ?? err?.status?.StatusCode;
const num = Number(codeProp);
if (!Number.isNaN(num) && num > 0) return num;

View File

@@ -1,6 +1,7 @@
const { GraphQLClient } = require("graphql-request");
const queries = require("../graphql-client/queries");
const CreateRRLogEvent = require("./rr-logger-event");
const { extractRRXmlPair } = require("./rr-log-xml");
/** Get bearer token from the socket (same approach used elsewhere) */
const getAuthToken = (socket) =>
@@ -178,11 +179,23 @@ const insertRRFailedExportLog = async ({ socket, jobId, job, bodyshop, error, cl
const client = new GraphQLClient(endpoint, {});
client.setHeaders({ Authorization: `Bearer ${token}` });
const { requestXml, responseXml } = extractRRXmlPair(error);
const xmlFromError =
requestXml || responseXml
? {
...(requestXml ? { request: requestXml } : {}),
...(responseXml ? { response: responseXml } : {})
}
: undefined;
const meta = buildRRExportMeta({
result,
extra: {
error: error?.message || String(error),
classification: classification || undefined
classification: classification || undefined,
...(requestXml ? { requestXml } : {}),
...(responseXml ? { responseXml } : {}),
...(xmlFromError && !result?.xml ? { xml: xmlFromError } : {})
}
});

View File

@@ -1,6 +1,7 @@
const { buildRRRepairOrderPayload } = require("./rr-job-helpers");
const { buildClientAndOpts } = require("./rr-lookup");
const CreateRRLogEvent = require("./rr-logger-event");
const { withRRRequestXml } = require("./rr-log-xml");
const { extractRrResponsibilityCenters } = require("./rr-responsibility-centers");
const CdkCalculateAllocations = require("./rr-calculate-allocations").default;
const { resolveRROpCodeFromBodyshop } = require("./rr-utils");
@@ -147,10 +148,7 @@ const createMinimalRRRepairOrder = async (args) => {
const response = await client.createRepairOrder(payload, finalOpts);
CreateRRLogEvent(socket, "INFO", "RR minimal Repair Order created", {
payload,
response
});
CreateRRLogEvent(socket, "INFO", "RR minimal Repair Order created", withRRRequestXml(response, { payload, response }));
const data = response?.data || null;
const statusBlocks = response?.statusBlocks || {};
@@ -327,7 +325,7 @@ const updateRRRepairOrderWithFullData = async (args) => {
// Without this, Reynolds won't recognize the OpCode when we send rogg operations
// The rolabor section tells Reynolds "these jobs exist" even with minimal data
CreateRRLogEvent(socket, "INFO", "Sending full data for early RO (using create with roNo)", {
CreateRRLogEvent(socket, "INFO", "Preparing full data for early RO (using create with roNo)", {
roNo: String(roNo),
hasRolabor: !!payload.rolabor,
hasRogg: !!payload.rogg,
@@ -338,10 +336,18 @@ const updateRRRepairOrderWithFullData = async (args) => {
// Reynolds will merge this with the existing RO header
const response = await client.createRepairOrder(payload, finalOpts);
CreateRRLogEvent(socket, "INFO", "RR Repair Order full data sent", {
payload,
response
});
CreateRRLogEvent(
socket,
"INFO",
"Sending full data for early RO (using create with roNo)",
withRRRequestXml(response, {
roNo: String(roNo),
hasRolabor: !!payload.rolabor,
hasRogg: !!payload.rogg,
payload,
response
})
);
const data = response?.data || null;
const statusBlocks = response?.statusBlocks || {};
@@ -501,10 +507,7 @@ const exportJobToRR = async (args) => {
const response = await client.createRepairOrder(payload, finalOpts);
CreateRRLogEvent(socket, "INFO", "RR raw Repair Order created", {
payload,
response
});
CreateRRLogEvent(socket, "INFO", "RR raw Repair Order created", withRRRequestXml(response, { payload, response }));
const data = response?.data || null;
const statusBlocks = response?.statusBlocks || {};
@@ -603,10 +606,15 @@ const finalizeRRRepairOrder = async (args) => {
const rrRes = await client.updateRepairOrder(payload, finalOpts);
CreateRRLogEvent(socket, "SILLY", "RR Repair Order finalized", {
payload,
response: rrRes
});
CreateRRLogEvent(
socket,
"SILLY",
"RR Repair Order finalized",
withRRRequestXml(rrRes, {
payload,
response: rrRes
})
);
const data = rrRes?.data || null;
const statusBlocks = rrRes?.statusBlocks || {};

63
server/rr/rr-log-xml.js Normal file
View File

@@ -0,0 +1,63 @@
/**
* Extract request/response XML from RR response/result shapes.
* @param rrObj
* @returns {{requestXml: string|null, responseXml: string|null}}
*/
const extractRRXmlPair = (rrObj) => {
const xml = rrObj?.xml ?? rrObj?.meta?.xml;
let requestXml = null;
let responseXml = null;
if (typeof xml === "string") {
requestXml = xml;
} else {
if (typeof xml?.request === "string") requestXml = xml.request;
else if (typeof xml?.req === "string") requestXml = xml.req;
else if (typeof xml?.starXml === "string") requestXml = xml.starXml;
if (typeof xml?.response === "string") responseXml = xml.response;
}
if (!requestXml && typeof rrObj?.requestXml === "string") requestXml = rrObj.requestXml;
if (!requestXml && typeof rrObj?.meta?.requestXml === "string") requestXml = rrObj.meta.requestXml;
if (!requestXml && typeof rrObj?.meta?.reqXml === "string") requestXml = rrObj.meta.reqXml;
if (!requestXml && typeof rrObj?.meta?.request === "string") requestXml = rrObj.meta.request;
if (!responseXml && typeof rrObj?.responseXml === "string") responseXml = rrObj.responseXml;
if (!responseXml && typeof rrObj?.meta?.responseXml === "string") responseXml = rrObj.meta.responseXml;
if (!responseXml && typeof rrObj?.meta?.resXml === "string") responseXml = rrObj.meta.resXml;
if (!responseXml && typeof rrObj?.meta?.response === "string") responseXml = rrObj.meta.response;
// If wrapped HTTP response data contains raw XML, surface it.
if (!responseXml && typeof rrObj?.response?.data === "string") {
const xmlData = rrObj.response.data.trim();
if (xmlData.startsWith("<")) responseXml = xmlData;
}
// Try one level down when errors are wrapped.
if ((!requestXml || !responseXml) && rrObj?.cause && rrObj.cause !== rrObj) {
const nested = extractRRXmlPair(rrObj.cause);
if (!requestXml) requestXml = nested.requestXml;
if (!responseXml) responseXml = nested.responseXml;
}
return { requestXml, responseXml };
};
/**
* Add Reynolds request/response XML to RR log metadata when available.
* @param rrObj
* @param meta
* @returns {*}
*/
const withRRRequestXml = (rrObj, meta = {}) => {
const { requestXml, responseXml } = extractRRXmlPair(rrObj);
const xmlMeta = {};
if (requestXml) xmlMeta.requestXml = requestXml;
if (responseXml) xmlMeta.responseXml = responseXml;
return Object.keys(xmlMeta).length ? { ...meta, ...xmlMeta } : meta;
};
module.exports = {
extractRRXmlPair,
withRRRequestXml
};

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