Compare commits

...

97 Commits

Author SHA1 Message Date
Dave
c33d743fe3 Merge remote-tracking branch 'origin/master-AIO' into release/2026-02-27 2026-03-04 12:38:59 -05: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
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
db4d286a86 feature/IO-3554-Form-Row-Layout - dial in tables 2026-03-02 11:59:32 -05: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
Dave
56c24e3450 feature/IO-3554-Form-Row-Layout - Modify how truncation works on responsive tables. 2026-03-02 11:29:06 -05:00
Allan Carr
9a41cfd6af Merged in feature/IO-3585-Fortellis-Insert-and-Update-Vehicle (pull request #3064)
IO-3585 Fortellis Insert and Update Vehicle Info

Approved-by: Dave Richer
2026-03-02 15:44:29 +00:00
Dave
2934da4be9 Merge branch 'feature/IO-3586-Socket-Reconnect-Issues' into release/2026-02-27 2026-03-02 10:43:42 -05:00
Dave
1fa6280876 feature/IO-3586-Socket-Reconnect-Issues - Fix 2026-03-02 10:41:24 -05:00
Dave Richer
ccba7b0137 Merged in hotfix/2026-06-27 (pull request #3061)
hotfix/2026-02-27 - Reynolds Estimate amounts on second call, + RR Docs in _ref
2026-02-27 21:15:52 +00:00
Dave Richer
c116007042 Merged in feature/IO-3554-Form-Row-Layout (pull request #3059)
feature/feature/IO-3554-Form-Row-Layout - Fix Monthly Job Costing (Dashboard)
2026-02-27 18:09:12 +00:00
Dave
31c7abab39 feature/feature/IO-3554-Form-Row-Layout - Fix Monthly Job Costing (Dashboard) 2026-02-27 13:08:34 -05:00
Dave Richer
589e537c94 Merged in feature/IO-3554-Form-Row-Layout (pull request #3057)
feature/feature/IO-3554-Form-Row-Layout - Revert Monthly Job Costing Table
2026-02-27 15:54:54 +00:00
Dave
b2f471fe9a feature/feature/IO-3554-Form-Row-Layout - Revert Monthly Job Costing Table 2026-02-27 10:53:57 -05:00
Dave Richer
7ea4f96664 Merged in feature/IO-3554-Form-Row-Layout (pull request #3055)
feature/feature/IO-3554-Form-Row-Layout - Responsive overhaul
2026-02-26 21:01:30 +00:00
Dave
fd6f46e39d feature/feature/IO-3554-Form-Row-Layout - Responsive overhaul 2026-02-26 15:56:57 -05:00
Dave Richer
0b505b3b4b Merged in feature/IO-3554-Form-Row-Layout (pull request #3053)
feature/feature/IO-3554-Form-Row-Layout - Drawer changes, mask closable changes, missing translation, spinner tip changes
2026-02-25 20:59:04 +00:00
Dave
226cc801ae feature/feature/IO-3554-Form-Row-Layout - Drawer changes, mask closable changes, missing translation, spinner tip changes 2026-02-25 15:57:51 -05:00
Dave Richer
67396afeb7 Merged in feature/IO-3554-Form-Row-Layout (pull request #3051)
feature/feature/IO-3554-Form-Row-Layout - Fix search, bump deps, fix formlayout
2026-02-25 20:32:13 +00:00
Dave
dab66b4d66 feature/feature/IO-3554-Form-Row-Layout - Fix search, bump deps, fix formlayout 2026-02-25 15:31:42 -05:00
Dave Richer
20d51431e7 Merged in feature/IO-3554-Form-Row-Layout (pull request #3049)
feature/feature/IO-3554-Form-Row-Layout - Fix search, bump deps, fix formlayout
2026-02-25 20:23:01 +00:00
Dave
15bb1e72a2 feature/feature/IO-3554-Form-Row-Layout - Fix search, bump deps, fix formlayout 2026-02-25 15:22:39 -05:00
Dave Richer
5edab6d040 Merged in feature/IO-3554-Form-Row-Layout (pull request #3047)
feature/feature/IO-3554-Form-Row-Layout - Fix search, bump deps, fix formlayout
2026-02-25 20:04:19 +00:00
Dave
48017e7471 feature/feature/IO-3554-Form-Row-Layout - Fix search, bump deps, fix formlayout 2026-02-25 15:03:53 -05:00
Dave Richer
acb1cc6367 Merged in feature/IO-3554-Form-Row-Layout (pull request #3004)
Responsive Part 1 - Form Layout
2026-02-25 19:48:47 +00:00
Dave
77befd5d93 feature/feature/IO-3554-Form-Row-Layout - Fix search, bump deps, fix formlayout 2026-02-25 14:47:49 -05:00
Dave
c93b8ed961 Merge remote-tracking branch 'origin/release/2026-02-27' into feature/IO-3554-Form-Row-Layout 2026-02-25 13:56:40 -05:00
Allan Carr
4d58c46a33 Merged in feature/IO-3578-Fortellis-Regex-Fix (pull request #3041)
IO-3578 Fortellis Regex Fix
2026-02-25 00:30:33 +00:00
Dave Richer
7299020bd8 Merged in release/2026-02-27 (pull request #3040)
Release/2026 02 27
2026-02-24 18:08:52 +00:00
Dave
f16a0c491b Merge remote-tracking branch 'origin/feature/IO-3537-Bill-Entry-Scroll-to-Top-for-Errors' into release/2026-02-27 2026-02-24 11:08:46 -05:00
Allan Carr
ae52f12bae Merged in feature/IO-3560-Part-Number-on-Return-Item-Modal (pull request #3035)
IO-3560 Part # on Return Item Modal

Approved-by: Dave Richer
2026-02-24 15:26:39 +00:00
Allan Carr
11475afdb1 Merged in feature/IO-3573-Enhanced-Payroll-Labor-Allocations (pull request #3034)
IO-3573 Enhanced Payroll Labor Allocations

Approved-by: Dave Richer
2026-02-24 15:26:07 +00:00
Allan Carr
7a5e722ec1 Merged in feature/IO-3575-Flat-Rate-ATS-PST-Exempt (pull request #3033)
IO-3575 Extend Audit Trail for Tax Rates and Flat ATS

Approved-by: Dave Richer
2026-02-24 15:25:40 +00:00
Allan Carr
7c686e38da Merge branch 'release/2026-02-27' into feature/IO-3537-Bill-Entry-Scroll-to-Top-for-Errors
Signed-off-by: Allan Carr <allan@imexsystems.ca>

# Conflicts:
#	client/src/components/bill-enter-modal/bill-enter-modal.container.jsx
2026-02-23 23:56:28 -08:00
Allan Carr
9eaf45ac88 IO-3537 Bill Entry Scroll to Top for Errors
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-23 23:44:24 -08:00
Allan Carr
8cd2e65305 IO-3560 Part # on Return Item Modal
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-23 19:09:35 -08:00
Allan Carr
da9744da6f IO-3573 Enhanced Payroll Labor Allocation
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-23 18:33:57 -08:00
Allan Carr
947ded4b5e IO-3573 Enhanced Payroll Labor Allocations
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-23 18:20:57 -08:00
Allan Carr
6e6304124b IO-3575 Extend Audit Trail for Tax Rates and Flat ATS
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-23 17:59:39 -08:00
Allan Carr
2f694c2638 Merged in feature/IO-3576-Fortellis-Refetch-Make-Model (pull request #3029)
IO-3576 Fortellis Refetch Make Model
2026-02-23 23:27:41 +00:00
Patrick Fic
5f8a08b0a7 IO-3515 Limit logging. 2026-02-23 11:55:22 -08:00
Patrick Fic
fd7970df2c Merge branch 'feature/IO-3515-ocr-bill-posting' into release/2026-02-27 2026-02-23 11:28:45 -08:00
Patrick Fic
03ad66b2a2 IO-3515 PR Comments addressed. 2026-02-20 09:06:11 -08:00
Patrick Fic
6f80e6dcbf IO-3515 fix notifications & auto attach document. 2026-02-19 15:36:40 -08:00
Patrick Fic
21f43285bc IO-3515 additional cleanup, translations 2026-02-19 14:15:57 -08:00
Patrick Fic
b2bc19c5c9 IO-3515 Add translations and logging. 2026-02-19 13:54:39 -08:00
Allan Carr
e075361e23 Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3025)
IO-3570 Strip - from Owner Name in regex
2026-02-19 21:17:44 +00:00
Allan Carr
a6327912ab Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3021)
IO-3570 Fortellis Owner Phone Search
2026-02-19 20:35:41 +00:00
Patrick Fic
ae1408012f IO-3515 resolve issues on search selects not updating, improve confidence scoring. 2026-02-19 12:22:35 -08:00
Allan Carr
c8b7d7461a Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3019)
Feature/IO-3570 Fortellis Multiple Veh Records
2026-02-19 20:03:48 +00:00
Allan Carr
48755dfa58 Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3017)
Feature/IO-3570 Fortellis Multiple Veh Records
2026-02-19 18:53:47 +00:00
Allan Carr
3be344b595 Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3012)
IO-3570 Filter Vehicle Results from VIN Query to records that only have a vehicleVehID
2026-02-19 17:56:50 +00:00
Patrick Fic
5d53d09af9 IO-3515 set po context, update confidence UI showing 2026-02-18 11:57:56 -08:00
Patrick Fic
d4bbdd7383 IO-3515 Improve confidence display. 2026-02-18 10:56:51 -08:00
Dave Richer
8b55df8624 Merged in feature/IO-3544-Ant-Select-Deprecation (pull request #3010)
feature/IO-3544-Ant-Select-Deprecation - Make cdklike-dms-post-form.jsx compatible with search on the payer name
2026-02-18 18:51:37 +00:00
Dave
8422ea83ae feature/IO-3544-Ant-Select-Deprecation - Make cdklike-dms-post-form.jsx compatible with search on the payer name 2026-02-18 13:50:46 -05:00
Patrick Fic
e5f930b8c8 IO-3515 Refactor button to separate component. 2026-02-18 10:32:44 -08:00
Patrick Fic
6d94265081 Package lock updates. 2026-02-18 10:08:28 -08:00
Patrick Fic
d9e75fe775 Merge branch 'master-AIO' into feature/IO-3515-ocr-bill-posting 2026-02-18 10:08:25 -08:00
Dave Richer
94c3ab6e1b Merged in feature/IO-3544-Ant-Select-Deprecation (pull request #3005)
Feature/IO-3544 Ant Select Deprecation
2026-02-18 17:41:33 +00:00
Dave
1b84087ef8 feature/IO-3544-Ant-Select-Deprecation - Package Bumps 2026-02-18 12:31:55 -05:00
Dave
a9fdf3da18 Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3544-Ant-Select-Deprecation 2026-02-18 12:25:42 -05:00
Dave Richer
6ae4e228ce Merged in release/2026-02-13 (pull request #3001)
Release/2026 02 13
2026-02-13 00:33:10 +00:00
Dave Richer
49fb2caac0 Merged in release/2026-02-13 (pull request #3000)
Release/2026 02 13
2026-02-13 00:32:41 +00:00
Dave
673670eeb4 Merge remote-tracking branch 'origin/release/2026-02-13' into feature/IO-3544-Ant-Select-Deprecation
# Conflicts:
#	client/src/components/jobs-convert-button/jobs-convert-button.component.jsx
2026-02-11 18:23:02 -05:00
Dave Richer
d9b3730db9 Merged in release/2026-02-13 (pull request #2992)
feature/IO-3558-Reynolds-Part-2 - Admin Panel
2026-02-11 23:14:02 +00:00
Dave Richer
313a90e8f3 Merged in release/2026-02-13 (pull request #2989)
Release/2026 02 13
2026-02-11 23:11:42 +00:00
Dave
2a352b60a0 Merge remote-tracking branch 'origin/release/2026-02-13' into feature/IO-3554-Form-Row-Layout 2026-02-11 10:15:56 -05:00
Dave
e6100851b8 feature/IO-3544-Ant-Select-Deprecation - Packages 2026-02-11 10:14:55 -05:00
Dave
e9795072d5 Merge remote-tracking branch 'origin/release/2026-02-13' into feature/IO-3544-Ant-Select-Deprecation 2026-02-11 10:05:58 -05:00
Patrick Fic
64454dce2a IO-3515 add client side polling for now, cost centers. 2026-02-10 11:59:53 -08:00
Patrick Fic
c59acb1b72 IO-3515 add confidence scoring 2026-02-09 14:47:20 -08:00
Dave Richer
773f3d4c84 Merged in release/2026-02-13 (pull request #2969)
IO-3533 Actual Cost Click to Focus
2026-02-06 19:54:36 +00:00
Dave
5ae0e8e4d5 Initial 2026-02-06 10:43:58 -05:00
Dave
40d5e02415 feature/IO-3feature/IO-3544-Ant-Select-Deprecation: Dep Bumps 2026-02-05 13:55:50 -05:00
Dave
5b891281d1 Merge remote-tracking branch 'origin/release/2026-02-13' into feature/IO-3544-Ant-Select-Deprecation
# Conflicts:
#	client/src/components/bill-form/bill-form.lines.component.jsx
2026-02-03 16:52:36 -05:00
Dave
71043313d6 feature/IO-3544-Ant-Select-Deprecation - Fix filtering 2026-02-03 12:40:01 -05:00
Dave
c9620a3f6f feature/IO-3544-Ant-Select-Deprecation - Fix filtering 2026-02-03 11:10:44 -05:00
Dave
cdfae5a429 feature/IO-3544-Ant-Select-Deprecation - finish 2026-02-03 10:51:14 -05:00
Patrick Fic
20dad2caba IO-3515 Minimally functional form fill out. 2026-01-29 16:26:16 -08:00
Patrick Fic
96731a29e1 Remove test data. 2026-01-28 16:23:15 -08:00
Patrick Fic
83be45a40b IO-3515 Checkin. Crude form update with some correct values. Pricing still significantly out. 2026-01-28 16:20:27 -08:00
Patrick Fic
55de16281d IO-3515 Bill OCR refactor to split files and introduce generator. 2026-01-28 14:32:11 -08:00
Patrick Fic
ad7e85a578 IO-3515 bifurcate single/multi page extract, add check for polling, add field labels 2026-01-27 15:40:13 -08:00
Patrick Fic
2a6d0446f0 IO-3515 WIP - bulk calls functioning. Further refinement required. 2026-01-26 16:09:58 -08:00
Patrick Fic
c3718fff87 IO-3515 Additional packages and initial route &n simple queue polling. 2026-01-23 15:04:24 -08:00
18 changed files with 962 additions and 734 deletions

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

@@ -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

@@ -471,31 +471,41 @@
// padding: 0;
//}
/* globally allow shrink inside table cells */
.prod-list-table .ant-table-cell,
.prod-list-table .ant-table-cell > * {
min-width: 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;
//}
/* 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 {
// This is the extra value render that causes the “duplicate text”
.ant-select-selection-item {
position: absolute !important;
left: -10000px !important;
pointer-events: none !important;
}
}

View File

@@ -4,7 +4,6 @@ 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
@@ -22,32 +21,23 @@ export function DmsLogEvents({
colorizeJson = false,
showDetails = 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;
let style = document.getElementById("json-highlight-styles");
if (!style) {
style = document.createElement("style");
style.id = "json-highlight-styles";
document.head.appendChild(style);
}
if (document.getElementById("json-highlight-styles")) return;
const style = document.createElement("style");
style.id = "json-highlight-styles";
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
@@ -75,13 +65,6 @@ 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 ? 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,
@@ -109,42 +92,10 @@ export function DmsLogEvents({
return next;
})
}
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
style={{ cursor: "pointer", userSelect: "none" }}
>
{isOpen ? t("dms.labels.hide_details") : t("dms.labels.details")}
{isOpen ? "Hide details" : "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>
@@ -152,30 +103,14 @@ export function DmsLogEvents({
{/* Row 2: details body (only when open) */}
{hasMeta && isOpen && (
<div style={{ marginLeft: 6 }}>
<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}
/>
)}
<JsonBlock isDarkMode={isDarkMode} data={meta} colorize={colorizeJson} />
</div>
)}
</Space>
)
};
}),
[logs, openSet, colorizeJson, copiedKey, isDarkMode, showDetails, t]
[logs, openSet, colorizeJson, isDarkMode, showDetails]
);
return <Timeline reverse items={items} />;
@@ -244,121 +179,6 @@ 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
@@ -390,105 +210,6 @@ 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

@@ -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

@@ -0,0 +1,13 @@
import { Table } from "antd";
function ResponsiveTable(props) {
return <Table {...props} />;
}
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

@@ -21,6 +21,7 @@ import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component";
import TimeTicketList from "../time-ticket-list/time-ticket-list.component";
import JobEmployeeAssignmentsContainer from "./../job-employee-assignments/job-employee-assignments.container";
import { PayrollLaborAllocationsTable } from "../labor-allocations-table/labor-allocations-table.payroll.component.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -46,10 +47,10 @@ export function TimeTicketModalComponent({
} = useTreatmentsWithConfig({
attributes: {},
names: ["Enhanced_Payroll"],
splitKey: bodyshop.imexshopid
splitKey: bodyshop?.imexshopid
});
const [loadLineTicketData, { loading, data: lineTicketData }] = useLazyQuery(GET_LINE_TICKET_BY_PK, {
const [loadLineTicketData, { loading, data: lineTicketData, refetch }] = useLazyQuery(GET_LINE_TICKET_BY_PK, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
@@ -91,21 +92,22 @@ export function TimeTicketModalComponent({
value={value === "timetickets.labels.shift" ? t(value) : value}
{...props}
disabled={value === "timetickets.labels.shift" || disabled}
>
{emps &&
emps.rates.map((item) => (
<Select.Option key={item.cost_center} value={item.cost_center}>
{item.cost_center === "timetickets.labels.shift"
options={
emps &&
emps.rates.map((item) => ({
value: item.cost_center,
label:
item.cost_center === "timetickets.labels.shift"
? t(item.cost_center)
: bodyshop.cdk_dealerid ||
bodyshop.pbs_serialnumber ||
bodyshop.rr_dealerid ||
Enhanced_Payroll.treatment === "on"
? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`)
: item.cost_center}
</Select.Option>
))}
</Select>
: item.cost_center
}))
}
/>
);
const MemoInput = ({ value, ...props }) => (
@@ -320,13 +322,34 @@ export function TimeTicketModalComponent({
</Form.Item>
</LayoutFormRow>
<LaborAllocationContainer jobid={watchedJobId || null} loading={loading} lineTicketData={lineTicketData} />
<LaborAllocationContainer
jobid={watchedJobId || null}
loading={loading}
lineTicketData={lineTicketData}
bodyshop={bodyshop}
refetch={refetch}
/>
</div>
);
}
export function LaborAllocationContainer({ jobid, loading, lineTicketData, hideTimeTickets = false }) {
export function LaborAllocationContainer({
jobid,
loading,
lineTicketData,
hideTimeTickets = false,
bodyshop,
refetch
}) {
const { t } = useTranslation();
const {
treatments: { Enhanced_Payroll }
} = useTreatmentsWithConfig({
attributes: {},
names: ["Enhanced_Payroll"],
splitKey: bodyshop?.imexshopid
});
if (loading) return <LoadingSkeleton />;
if (!lineTicketData) return null;
if (!jobid) return null;
@@ -337,12 +360,23 @@ export function LaborAllocationContainer({ jobid, loading, lineTicketData, hideT
<JobEmployeeAssignmentsContainer job={lineTicketData.jobs_by_pk} />
</Card>
<LaborAllocationsTable
jobId={jobid}
joblines={lineTicketData.joblines}
timetickets={lineTicketData.timetickets}
adjustments={lineTicketData.jobs_by_pk.lbr_adjustments}
/>
{Enhanced_Payroll.treatment === "on" ? (
<PayrollLaborAllocationsTable
jobId={jobid}
joblines={lineTicketData.joblines}
timetickets={lineTicketData.timetickets}
adjustments={lineTicketData.jobs_by_pk.lbr_adjustments}
refetch={refetch}
bodyshop={bodyshop}
/>
) : (
<LaborAllocationsTable
jobId={jobid}
joblines={lineTicketData.joblines}
timetickets={lineTicketData.timetickets}
adjustments={lineTicketData.jobs_by_pk.lbr_adjustments}
/>
)}
{!hideTimeTickets && (
<TimeTicketList loading={loading} timetickets={jobid ? lineTicketData.timetickets : []} techConsole />

View File

@@ -163,21 +163,19 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
const providerLabel = useMemo(
() =>
({
[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]
[DMS_MAP.reynolds]: "Reynolds",
[DMS_MAP.fortellis]: "Fortellis",
[DMS_MAP.cdk]: "CDK",
[DMS_MAP.pbs]: "PBS"
})[mode] || "DMS",
[mode]
);
const transportLabel = isWssMode(mode) ? t("dms.labels.transport_wss") : t("dms.labels.transport_ws");
const transportLabel = isWssMode(mode) ? "(WSS)" : "(WS)";
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 bannerMessage = `Posting to ${providerLabel} | ${transportLabel} | ${
isConnected ? "Connected" : "Disconnected"
}`;
const resetKey = useMemo(() => `${mode || "none"}-${jobId || "none"}`, [mode, jobId]);
@@ -226,7 +224,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 ? t("dms.labels.provider_reynolds") : t("dms.labels.provider_dms"));
const vendorTitle = title || (isRrMode ? "Reynolds" : "DMS");
const isRrOpenRoLimit =
isRrMode &&
@@ -301,9 +299,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
{
timestamp: new Date(),
level: "warn",
message: t("dms.labels.reconnected_export_service", {
provider: isRrMode ? t("dms.labels.provider_reynolds") : providerLabel
})
message: `Reconnected to ${isRrMode ? "RR" : mode === DMS_MAP.fortellis ? "Fortellis" : "DMS"} Export Service`
}
]);
};
@@ -362,12 +358,14 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
{
timestamp: new Date(),
level: "INFO",
message: t("dms.labels.rr_validation_message")
message:
"Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize."
}
]);
notification.info({
title: t("dms.labels.rr_validation_notice_title"),
description: t("dms.labels.rr_validation_notice_description"),
title: "Reynolds RO created",
description:
"Complete validation in Reynolds, then click Finished/Close to finalize and mark this export complete.",
duration: 8
});
};
@@ -379,7 +377,8 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
{
timestamp: new Date(),
level: "INFO",
message: t("dms.labels.rr_validation_message"),
message:
"Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize.",
meta: { payload }
}
]);
@@ -407,7 +406,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
activeSocket.disconnect();
}
};
}, [mode, activeSocket, channels, logLevel, notification, t, insertAuditTrail, history, isRrMode, providerLabel]);
}, [mode, activeSocket, channels, logLevel, notification, t, insertAuditTrail, history]);
// RR finalize callback (unchanged public behavior)
const handleRrValidationFinished = () => {
@@ -429,7 +428,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
// Check if Reynolds mode requires early RO
const hasEarlyRO = !!(data.jobs_by_pk?.dms_id && data.jobs_by_pk?.dms_customer_id && data.jobs_by_pk?.dms_advisor_id);
if (isRrMode && !hasEarlyRO) {
return (
<Result
@@ -450,7 +449,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
<AlertComponent style={{ marginBottom: 10 }} title={bannerMessage} type="warning" showIcon closable />
<Row gutter={[16, 16]}>
<Col xs={24} xxl={10} className="dms-equal-height-col dms-top-panel-col">
<Col md={24} lg={10} className="dms-equal-height-col">
{!isRrMode ? (
<DmsAllocationsSummary
key={resetKey}
@@ -490,7 +489,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
)}
</Col>
<Col xs={24} xxl={14} className="dms-equal-height-col dms-top-panel-col">
<Col md={24} lg={14} className="dms-equal-height-col">
<DmsPostForm
key={resetKey}
socket={activeSocket}
@@ -528,17 +527,15 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
<Switch
checked={colorizeJson}
onChange={setColorizeJson}
checkedChildren={t("dms.labels.color_json")}
unCheckedChildren={t("dms.labels.plain_json")}
checkedChildren="Color JSON"
unCheckedChildren="Plain JSON"
/>
<Button onClick={toggleDetailsAll}>
{detailsOpen ? t("dms.labels.collapse_all") : t("dms.labels.expand_all")}
</Button>
<Button onClick={toggleDetailsAll}>{detailsOpen ? "Collapse All" : "Expand All"}</Button>
</>
)}
<Select
placeholder={t("dms.labels.log_level")}
placeholder="Log Level"
value={logLevel}
onChange={(value) => {
setLogLevel(value);
@@ -551,7 +548,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
<Select.Option key="WARN">WARN</Select.Option>
<Select.Option key="ERROR">ERROR</Select.Option>
</Select>
<Button onClick={() => setLogs([])}>{t("dms.labels.clear_logs")}</Button>
<Button onClick={() => setLogs([])}>Clear Logs</Button>
<Button
onClick={() => {
setLogs([]);
@@ -565,7 +562,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
}
}}
>
{t("dms.labels.reconnect")}
Reconnect
</Button>
</Space>
}

View File

@@ -1052,36 +1052,7 @@
"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.",
"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"
"refreshallocations": "Refresh to see DMS Allocations."
}
},
"documents": {

View File

@@ -1052,36 +1052,7 @@
"earlyrorequired.message": ""
},
"labels": {
"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": ""
"refreshallocations": ""
}
},
"documents": {

View File

@@ -1052,36 +1052,7 @@
"earlyrorequired.message": ""
},
"labels": {
"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": ""
"refreshallocations": ""
}
},
"documents": {

View File

@@ -757,13 +757,7 @@ async function InsertDmsVehicle({ socket, redisHelpers, JobData, txEnvelope, DMS
modelAbrev: txEnvelope.dms_model,
// "modelDescription": "SILVERADO 1500 2WD EXT CAB LT",
// "modelType": "T",
modelYear:
JobData.v_model_yr &&
(JobData.v_model_yr < 100
? JobData.v_model_yr >= (moment().year() + 1) % 100
? 1900 + parseInt(JobData.v_model_yr, 10)
: 2000 + parseInt(JobData.v_model_yr, 10)
: JobData.v_model_yr),
modelYear: JobData.v_model_yr,
// "numberOfEngineCylinders": 4,
odometerStatus: txEnvelope.kmout,
// "options": [
@@ -922,10 +916,6 @@ async function UpdateDmsVehicle({ socket, redisHelpers, JobData, DMSVeh, DMSCust
delete DMSVehToSend.invoice;
delete DMSVehToSend.inventoryAccount;
!DMSVehToSend.vehicle.engineNumber && delete DMSVehToSend.vehicle.engineNumber;
!DMSVehToSend.vehicle.saleClassValue && DMSVehToSend.vehicle.saleClassValue === "MISC";
!DMSVehToSend.vehicle.exteriorColor && delete DMSVehToSend.vehicle.exteriorColor;
const result = await MakeFortellisCall({
...FortellisActions.UpdateVehicle,
requestSearchParams: {},

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

@@ -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
};

View File

@@ -12,6 +12,7 @@ const { createRRCustomer } = require("./rr-customers");
const { ensureRRServiceVehicle } = require("./rr-service-vehicles");
const { classifyRRVendorError } = require("./rr-errors");
const { markRRExportSuccess, insertRRFailedExportLog } = require("./rr-export-logs");
const { withRRRequestXml } = require("./rr-log-xml");
const {
makeVehicleSearchPayloadFromJob,
ownersFromVinBlocks,
@@ -48,46 +49,6 @@ const resolveJobId = (explicit, payload, job) => explicit || payload?.jobId || j
*/
const resolveVin = ({ tx, job }) => tx?.jobData?.vin || job?.v_vin || null;
/**
* 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;
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 (!responseXml && typeof rrObj?.responseXml === "string") responseXml = rrObj.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;
};
/**
* Sort vehicle owners first in the list, preserving original order otherwise.
* @param list
@@ -194,13 +155,15 @@ const setJobDmsIdForSocket = async ({ socket, jobId, dmsId, dmsCustomerId, dmsAd
if (!token) throw new Error("Missing auth token for setJobDmsIdForSocket");
const client = new GraphQLClient(endpoint, {});
await client.setHeaders({ Authorization: `Bearer ${token}` }).request(queries.SET_JOB_DMS_ID, {
id: jobId,
dms_id: String(dmsId),
dms_customer_id: dmsCustomerId ? String(dmsCustomerId) : null,
dms_advisor_id: dmsAdvisorId ? String(dmsAdvisorId) : null,
kmin: mileageIn != null && mileageIn > 0 ? parseInt(mileageIn, 10) : null
});
await client
.setHeaders({ Authorization: `Bearer ${token}` })
.request(queries.SET_JOB_DMS_ID, {
id: jobId,
dms_id: String(dmsId),
dms_customer_id: dmsCustomerId ? String(dmsCustomerId) : null,
dms_advisor_id: dmsAdvisorId ? String(dmsAdvisorId) : null,
kmin: mileageIn != null && mileageIn > 0 ? parseInt(mileageIn, 10) : null
});
CreateRRLogEvent(socket, "INFO", "Linked job.dms_id to RR RO", {
jobId,
@@ -279,7 +242,12 @@ const rrMultiCustomerSearch = async ({ bodyshop, job, socket, redisHelpers }) =>
const multiResponse = await rrCombinedSearch(bodyshop, q);
CreateRRLogEvent(socket, "SILLY", "Multi Customer Search - raw combined search", { response: multiResponse });
CreateRRLogEvent(
socket,
"SILLY",
"Multi Customer Search - raw combined search",
withRRRequestXml(multiResponse, { response: multiResponse })
);
if (fromVin) {
const multiBlocks = Array.isArray(multiResponse?.data) ? multiResponse.data : [];
@@ -300,7 +268,7 @@ const rrMultiCustomerSearch = async ({ bodyshop, job, socket, redisHelpers }) =>
const norm = normalizeCustomerCandidates(multiResponse, { ownersSet });
merged.push(...norm);
} catch (e) {
CreateRRLogEvent(socket, "WARN", "Multi-search subquery failed", { kind: q.kind, error: e.message });
CreateRRLogEvent(socket, "WARN", "Multi-search subquery failed", withRRRequestXml(e, { kind: q.kind, error: e.message }));
}
}
@@ -348,7 +316,7 @@ const registerRREvents = ({ socket, redisHelpers }) => {
count: decorated.length
});
} catch (e) {
CreateRRLogEvent(socket, "ERROR", "RR combined lookup error", { error: e.message, jobid });
CreateRRLogEvent(socket, "ERROR", "RR combined lookup error", withRRRequestXml(e, { error: e.message, jobid }));
cb?.({ jobid, error: e.message });
}
});
@@ -425,7 +393,7 @@ const registerRREvents = ({ socket, redisHelpers }) => {
fromCache
});
} catch (err) {
CreateRRLogEvent(socket, "ERROR", "rr-get-advisors: failed", { error: err?.message });
CreateRRLogEvent(socket, "ERROR", "rr-get-advisors: failed", withRRRequestXml(err, { error: err?.message }));
ack?.({ ok: false, error: err?.message || "get advisors failed" });
}
});
@@ -496,11 +464,16 @@ const registerRREvents = ({ socket, redisHelpers }) => {
anyOwner: decorated.some((c) => c.vinOwner || c.isVehicleOwner)
});
} catch (error) {
CreateRRLogEvent(socket, "ERROR", `Error during RR early RO creation (prepare)`, {
error: error.message,
stack: error.stack,
jobid: rid
});
CreateRRLogEvent(
socket,
"ERROR",
`Error during RR early RO creation (prepare)`,
withRRRequestXml(error, {
error: error.message,
stack: error.stack,
jobid: rid
})
);
try {
socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message });
@@ -549,11 +522,7 @@ const registerRREvents = ({ socket, redisHelpers }) => {
});
// Filter out invalid values
if (
selectedCustNo === "undefined" ||
selectedCustNo === "null" ||
(selectedCustNo && selectedCustNo.trim() === "")
) {
if (selectedCustNo === "undefined" || selectedCustNo === "null" || (selectedCustNo && selectedCustNo.trim() === "")) {
selectedCustNo = null;
}
@@ -597,7 +566,12 @@ const registerRREvents = ({ socket, redisHelpers }) => {
if (vehQ && vehQ.kind === "vin" && job?.v_vin) {
const vinResponse = await rrCombinedSearch(bodyshop, vehQ);
CreateRRLogEvent(socket, "SILLY", `VIN owner pre-check response (early RO)`, { response: vinResponse });
CreateRRLogEvent(
socket,
"SILLY",
`VIN owner pre-check response (early RO)`,
withRRRequestXml(vinResponse, { response: vinResponse })
);
const vinBlocks = Array.isArray(vinResponse?.data) ? vinResponse.data : [];
@@ -630,9 +604,14 @@ const registerRREvents = ({ socket, redisHelpers }) => {
}
}
} catch (e) {
CreateRRLogEvent(socket, "WARN", `VIN owner pre-check failed; continuing with selected customer (early RO)`, {
error: e?.message
});
CreateRRLogEvent(
socket,
"WARN",
`VIN owner pre-check failed; continuing with selected customer (early RO)`,
withRRRequestXml(e, {
error: e?.message
})
);
}
// Cache final/effective customer selection
@@ -747,52 +726,42 @@ const registerRREvents = ({ socket, redisHelpers }) => {
const outsdRoNo = data?.outsdRoNo ?? job?.ro_number ?? job?.id ?? null;
CreateRRLogEvent(
socket,
"DEBUG",
"Early RO created - checking dmsRoNo",
withRRRequestXml(result, {
dmsRoNo,
resultRoNo: result?.roNo,
dataRoNo: data?.dmsRoNo,
jobId: rid
})
);
CreateRRLogEvent(socket, "DEBUG", "Early RO created - checking dmsRoNo", {
dmsRoNo,
resultRoNo: result?.roNo,
dataRoNo: data?.dmsRoNo,
jobId: rid
});
// ✅ Persist DMS RO number, customer ID, advisor ID, and mileage on the job
if (dmsRoNo) {
const mileageIn = txEnvelope?.kmin ?? null;
CreateRRLogEvent(socket, "DEBUG", "Calling setJobDmsIdForSocket", {
jobId: rid,
CreateRRLogEvent(socket, "DEBUG", "Calling setJobDmsIdForSocket", {
jobId: rid,
dmsId: dmsRoNo,
customerId: effectiveCustNo,
advisorId: String(advisorNo),
mileageIn
});
await setJobDmsIdForSocket({
socket,
jobId: rid,
await setJobDmsIdForSocket({
socket,
jobId: rid,
dmsId: dmsRoNo,
dmsCustomerId: effectiveCustNo,
dmsAdvisorId: String(advisorNo),
mileageIn
});
} else {
CreateRRLogEvent(
socket,
"WARN",
"RR early RO creation succeeded but no DMS RO number was returned",
withRRRequestXml(result, {
jobId: rid,
resultPreview: {
roNo: result?.roNo,
data: {
dmsRoNo: data?.dmsRoNo,
outsdRoNo: data?.outsdRoNo
}
CreateRRLogEvent(socket, "WARN", "RR early RO creation succeeded but no DMS RO number was returned", {
jobId: rid,
resultPreview: {
roNo: result?.roNo,
data: {
dmsRoNo: data?.dmsRoNo,
outsdRoNo: data?.outsdRoNo
}
})
);
}
});
}
await redisHelpers.setSessionTransactionData(
@@ -810,15 +779,10 @@ const registerRREvents = ({ socket, redisHelpers }) => {
defaultRRTTL
);
CreateRRLogEvent(
socket,
"INFO",
`{EARLY-5} Minimal RO created successfully`,
withRRRequestXml(result, {
dmsRoNo: dmsRoNo || null,
outsdRoNo: outsdRoNo || null
})
);
CreateRRLogEvent(socket, "INFO", `{EARLY-5} Minimal RO created successfully`, {
dmsRoNo: dmsRoNo || null,
outsdRoNo: outsdRoNo || null
});
// Mark success in export logs
await markRRExportSuccess({
@@ -867,16 +831,11 @@ const registerRREvents = ({ socket, redisHelpers }) => {
message: vendorMessage
});
CreateRRLogEvent(
socket,
"ERROR",
`Early RO creation failed`,
withRRRequestXml(result, {
roStatus: result?.roStatus,
statusBlocks: result?.statusBlocks,
classification: cls
})
);
CreateRRLogEvent(socket, "ERROR", `Early RO creation failed`, {
roStatus: result?.roStatus,
statusBlocks: result?.statusBlocks,
classification: cls
});
await insertRRFailedExportLog({
socket,
@@ -905,14 +864,19 @@ const registerRREvents = ({ socket, redisHelpers }) => {
} catch (error) {
const cls = classifyRRVendorError(error);
CreateRRLogEvent(socket, "ERROR", `Error during RR early RO creation (customer-selected)`, {
error: error.message,
vendorStatusCode: cls.vendorStatusCode,
code: cls.errorCode,
friendly: cls.friendlyMessage,
stack: error.stack,
jobid: rid
});
CreateRRLogEvent(
socket,
"ERROR",
`Error during RR early RO creation (customer-selected)`,
withRRRequestXml(error, {
error: error.message,
vendorStatusCode: cls.vendorStatusCode,
code: cls.errorCode,
friendly: cls.friendlyMessage,
stack: error.stack,
jobid: rid
})
);
try {
if (!bodyshop || !job) {
@@ -1002,14 +966,14 @@ const registerRREvents = ({ socket, redisHelpers }) => {
// Check if this job already has an early RO - if so, use stored IDs and skip customer search
const hasEarlyRO = !!job?.dms_id;
if (hasEarlyRO) {
CreateRRLogEvent(socket, "DEBUG", `{2} Early RO exists - using stored customer/advisor`, {
dms_id: job.dms_id,
dms_customer_id: job.dms_customer_id,
dms_advisor_id: job.dms_advisor_id
});
// Cache the stored customer/advisor IDs for the next step
if (job.dms_customer_id) {
await redisHelpers.setSessionTransactionData(
@@ -1029,18 +993,18 @@ const registerRREvents = ({ socket, redisHelpers }) => {
defaultRRTTL
);
}
// Emit empty customer list to frontend (won't show modal)
socket.emit("rr-select-customer", []);
// Continue directly with the export by calling the selected customer handler logic inline
// This is essentially the same as if user selected the stored customer
const selectedCustNo = job.dms_customer_id;
if (!selectedCustNo) {
throw new Error("Early RO exists but no customer ID stored");
}
// Continue with ensureRRServiceVehicle and export (same as rr-selected-customer handler)
const { client, opts } = await buildClientAndOpts(bodyshop);
const routing = opts?.routing || client?.opts?.routing || null;
@@ -1073,12 +1037,7 @@ const registerRREvents = ({ socket, redisHelpers }) => {
redisHelpers
});
const advisorNo =
job.dms_advisor_id ||
readAdvisorNo(
{ txEnvelope },
await redisHelpers.getSessionTransactionData(socket.id, getTransactionType(rid), RRCacheEnums.AdvisorNo)
);
const advisorNo = job.dms_advisor_id || readAdvisorNo({ txEnvelope }, await redisHelpers.getSessionTransactionData(socket.id, getTransactionType(rid), RRCacheEnums.AdvisorNo));
if (!advisorNo) {
throw new Error("Advisor is required (advisorNo).");
@@ -1097,7 +1056,28 @@ const registerRREvents = ({ socket, redisHelpers }) => {
roNo: job.dms_id
});
CreateRRLogEvent(
socket,
"SILLY",
"{4.1} RR RO update response received",
withRRRequestXml(result, {
dmsRoNo: job.dms_id,
success: !!result?.success
})
);
if (!result?.success) {
CreateRRLogEvent(
socket,
"ERROR",
"RR Repair Order update failed",
withRRRequestXml(result, {
jobId: rid,
dmsRoNo: job.dms_id,
roStatus: result?.roStatus,
statusBlocks: result?.statusBlocks
})
);
throw new Error(result?.roStatus?.message || "Failed to update RR Repair Order");
}
@@ -1126,20 +1106,15 @@ const registerRREvents = ({ socket, redisHelpers }) => {
defaultRRTTL
);
CreateRRLogEvent(
socket,
"INFO",
`RR Repair Order updated successfully`,
withRRRequestXml(result, {
dmsRoNo,
jobId: rid
})
);
CreateRRLogEvent(socket, "INFO", `RR Repair Order updated successfully`, {
dmsRoNo,
jobId: rid
});
// For early RO flow, only emit validation-required (not export-job:result)
// since the export is not complete yet - we're just waiting for validation
socket.emit("rr-validation-required", { dmsRoNo, jobId: rid });
return ack?.({ ok: true, skipCustomerSelection: true, dmsRoNo });
}
@@ -1154,11 +1129,16 @@ const registerRREvents = ({ socket, redisHelpers }) => {
anyOwner: decorated.some((c) => c.vinOwner || c.isVehicleOwner)
});
} catch (error) {
CreateRRLogEvent(socket, "ERROR", `Error during RR export (prepare)`, {
error: error.message,
stack: error.stack,
jobid: rid
});
CreateRRLogEvent(
socket,
"ERROR",
`Error during RR export (prepare)`,
withRRRequestXml(error, {
error: error.message,
stack: error.stack,
jobid: rid
})
);
try {
socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message });
@@ -1220,7 +1200,12 @@ const registerRREvents = ({ socket, redisHelpers }) => {
if (vehQ && vehQ.kind === "vin" && job?.v_vin) {
const vinResponse = await rrCombinedSearch(bodyshop, vehQ);
CreateRRLogEvent(socket, "SILLY", `VIN owner pre-check response`, { response: vinResponse });
CreateRRLogEvent(
socket,
"SILLY",
`VIN owner pre-check response`,
withRRRequestXml(vinResponse, { response: vinResponse })
);
const vinBlocks = Array.isArray(vinResponse?.data) ? vinResponse.data : [];
@@ -1253,9 +1238,14 @@ const registerRREvents = ({ socket, redisHelpers }) => {
}
}
} catch (e) {
CreateRRLogEvent(socket, "WARN", `VIN owner pre-check failed; continuing with selected customer`, {
error: e?.message
});
CreateRRLogEvent(
socket,
"WARN",
`VIN owner pre-check failed; continuing with selected customer`,
withRRRequestXml(e, {
error: e?.message
})
);
}
// Cache final/effective customer selection
@@ -1349,25 +1339,25 @@ const registerRREvents = ({ socket, redisHelpers }) => {
// When updating an early RO, use stored customer/advisor IDs
let finalEffectiveCustNo = effectiveCustNo;
let finalAdvisorNo = advisorNo;
if (shouldUpdate && job?.dms_customer_id) {
CreateRRLogEvent(socket, "DEBUG", `Using stored customer ID from early RO`, {
CreateRRLogEvent(socket, "DEBUG", `Using stored customer ID from early RO`, {
storedCustomerId: job.dms_customer_id,
originalCustomerId: effectiveCustNo
originalCustomerId: effectiveCustNo
});
finalEffectiveCustNo = String(job.dms_customer_id);
}
if (shouldUpdate && job?.dms_advisor_id) {
CreateRRLogEvent(socket, "DEBUG", `Using stored advisor ID from early RO`, {
CreateRRLogEvent(socket, "DEBUG", `Using stored advisor ID from early RO`, {
storedAdvisorId: job.dms_advisor_id,
originalAdvisorId: advisorNo
originalAdvisorId: advisorNo
});
finalAdvisorNo = String(job.dms_advisor_id);
}
let result;
if (shouldUpdate) {
// UPDATE existing RO with full data
CreateRRLogEvent(socket, "DEBUG", `{4} Updating existing RR RO with full data`, { dmsRoNo: existingDmsId });
@@ -1416,21 +1406,16 @@ const registerRREvents = ({ socket, redisHelpers }) => {
if (dmsRoNo) {
await setJobDmsIdForSocket({ socket, jobId: rid, dmsId: dmsRoNo });
} else {
CreateRRLogEvent(
socket,
"WARN",
"RR export succeeded but no DMS RO number was returned",
withRRRequestXml(result, {
jobId: rid,
resultPreview: {
roNo: result?.roNo,
data: {
dmsRoNo: data?.dmsRoNo,
outsdRoNo: data?.outsdRoNo
}
CreateRRLogEvent(socket, "WARN", "RR export succeeded but no DMS RO number was returned", {
jobId: rid,
resultPreview: {
roNo: result?.roNo,
data: {
dmsRoNo: data?.dmsRoNo,
outsdRoNo: data?.outsdRoNo
}
})
);
}
});
}
await redisHelpers.setSessionTransactionData(
@@ -1447,15 +1432,10 @@ const registerRREvents = ({ socket, redisHelpers }) => {
defaultRRTTL
);
CreateRRLogEvent(
socket,
"INFO",
`{5} RO created. Waiting for validation.`,
withRRRequestXml(result, {
dmsRoNo: dmsRoNo || null,
outsdRoNo: outsdRoNo || null
})
);
CreateRRLogEvent(socket, "INFO", `{5} RO created. Waiting for validation.`, {
dmsRoNo: dmsRoNo || null,
outsdRoNo: outsdRoNo || null
});
// Tell FE to prompt for "Finished/Close"
socket.emit("rr-validation-required", { jobId: rid, dmsRoNo, outsdRoNo });
@@ -1494,16 +1474,11 @@ const registerRREvents = ({ socket, redisHelpers }) => {
message: vendorMessage
});
CreateRRLogEvent(
socket,
"ERROR",
`Export failed (step 1)`,
withRRRequestXml(result, {
roStatus: result?.roStatus,
statusBlocks: result?.statusBlocks,
classification: cls
})
);
CreateRRLogEvent(socket, "ERROR", `Export failed (step 1)`, {
roStatus: result?.roStatus,
statusBlocks: result?.statusBlocks,
classification: cls
});
await insertRRFailedExportLog({
socket,
@@ -1532,14 +1507,19 @@ const registerRREvents = ({ socket, redisHelpers }) => {
} catch (error) {
const cls = classifyRRVendorError(error);
CreateRRLogEvent(socket, "ERROR", `Error during RR export (selected-customer)`, {
error: error.message,
vendorStatusCode: cls.vendorStatusCode,
code: cls.errorCode,
friendly: cls.friendlyMessage,
stack: error.stack,
jobid: rid
});
CreateRRLogEvent(
socket,
"ERROR",
`Error during RR export (selected-customer)`,
withRRRequestXml(error, {
error: error.message,
vendorStatusCode: cls.vendorStatusCode,
code: cls.errorCode,
friendly: cls.friendlyMessage,
stack: error.stack,
jobid: rid
})
);
try {
if (!bodyshop || !job) {
@@ -1628,12 +1608,7 @@ const registerRREvents = ({ socket, redisHelpers }) => {
});
if (finalizeResult?.success) {
CreateRRLogEvent(
socket,
"INFO",
`{7} Finalize success; marking exported`,
withRRRequestXml(finalizeResult, { dmsRoNo, outsdRoNo })
);
CreateRRLogEvent(socket, "INFO", `{7} Finalize success; marking exported`, { dmsRoNo, outsdRoNo });
// ✅ Mark exported + success log
await markRRExportSuccess({
@@ -1676,17 +1651,6 @@ const registerRREvents = ({ socket, redisHelpers }) => {
message: vendorMessage
});
CreateRRLogEvent(
socket,
"ERROR",
"Finalize failed",
withRRRequestXml(finalizeResult, {
roStatus: finalizeResult?.roStatus,
statusBlocks: finalizeResult?.statusBlocks,
classification: cls
})
);
await insertRRFailedExportLog({
socket,
jobId: rid,
@@ -1707,14 +1671,19 @@ const registerRREvents = ({ socket, redisHelpers }) => {
}
} catch (error) {
const cls = classifyRRVendorError(error);
CreateRRLogEvent(socket, "ERROR", `Error during RR finalize`, {
error: error.message,
vendorStatusCode: cls.vendorStatusCode,
code: cls.errorCode,
friendly: cls.friendlyMessage,
stack: error.stack,
jobid: rid
});
CreateRRLogEvent(
socket,
"ERROR",
`Error during RR finalize`,
withRRRequestXml(error, {
error: error.message,
vendorStatusCode: cls.vendorStatusCode,
code: cls.errorCode,
friendly: cls.friendlyMessage,
stack: error.stack,
jobid: rid
})
);
try {
if (!bodyshop || !job) {

View File

@@ -1,5 +1,6 @@
const { buildClientAndOpts, rrCombinedSearch } = require("./rr-lookup");
const CreateRRLogEvent = require("./rr-logger-event");
const { withRRRequestXml } = require("./rr-log-xml");
/**
* Pick and normalize VIN from inputs
* @param vin
@@ -168,9 +169,12 @@ const ensureRRServiceVehicle = async (args = {}) => {
if (bodyshop) {
const combinedSearchResponse = await rrCombinedSearch(bodyshop, { kind: "vin", vin: vinStr, maxResults: 50 });
CreateRRLogEvent(socket, "silly", "{SV} Preflight combined search by VIN: raw response", {
response: combinedSearchResponse
});
CreateRRLogEvent(
socket,
"silly",
"{SV} Preflight combined search by VIN: raw response",
withRRRequestXml(combinedSearchResponse, { response: combinedSearchResponse })
);
owners = ownersFromCombined(combinedSearchResponse, vinStr);
}
@@ -194,10 +198,15 @@ const ensureRRServiceVehicle = async (args = {}) => {
}
} catch (e) {
// Preflight shouldn't be fatal; log and continue to insert (idempotency will still be handled)
CreateRRLogEvent(socket, "warn", "{SV} VIN preflight lookup failed; continuing to insert", {
vin: vinStr,
error: e?.message
});
CreateRRLogEvent(
socket,
"warn",
"{SV} VIN preflight lookup failed; continuing to insert",
withRRRequestXml(e, {
vin: vinStr,
error: e?.message
})
);
}
// Vendor says: MODEL DESCRIPTION HAS MAXIMUM LENGTH OF 20
@@ -271,7 +280,7 @@ const ensureRRServiceVehicle = async (args = {}) => {
try {
const res = await client.insertServiceVehicle(insertPayload, insertOpts);
CreateRRLogEvent(socket, "silly", "{SV} insertServiceVehicle: raw response", { res });
CreateRRLogEvent(socket, "silly", "{SV} insertServiceVehicle: raw response", withRRRequestXml(res, { res }));
const data = res?.data ?? {};
const svId = data?.dmsRecKey || data?.svId || undefined;
@@ -309,11 +318,16 @@ const ensureRRServiceVehicle = async (args = {}) => {
};
}
CreateRRLogEvent(socket, "error", "{SV} insertServiceVehicle: failure", {
message: e?.message,
code: e?.code,
status: e?.meta?.status || e?.status
});
CreateRRLogEvent(
socket,
"error",
"{SV} insertServiceVehicle: failure",
withRRRequestXml(e, {
message: e?.message,
code: e?.code,
status: e?.meta?.status || e?.status
})
);
throw e;
}