Compare commits
89 Commits
feature/IO
...
feature/IO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
745eb8e980 | ||
|
|
0b772133b8 | ||
|
|
318a3be786 | ||
|
|
665f09d832 | ||
|
|
3d7f2961fd | ||
|
|
af52c35013 | ||
|
|
36157d87bb | ||
|
|
722375fede | ||
|
|
339c19a041 | ||
|
|
8af8c8039c | ||
|
|
b8570f3ae9 | ||
|
|
6ef56f97c0 | ||
|
|
3a1d10b0d1 | ||
|
|
dd633cea89 | ||
|
|
e6071709be | ||
|
|
fb863c7979 | ||
|
|
c95c11fd0e | ||
|
|
1351fbb814 | ||
|
|
dcd3a078ef | ||
|
|
bb8e140f6e | ||
|
|
8102fd5177 | ||
|
|
bf11e10676 | ||
|
|
92e6bdf2a2 | ||
|
|
a02e336d73 | ||
|
|
7ec8a73c30 | ||
|
|
c7bb1a9c32 | ||
|
|
e669c19b98 | ||
|
|
5c55c0c74b | ||
|
|
f1f705903a | ||
|
|
6551be2d92 | ||
|
|
48e59fe849 | ||
|
|
7991192496 | ||
|
|
05cd60c2a1 | ||
|
|
26fc76a767 | ||
|
|
49816d5d43 | ||
|
|
b9b3e2c2aa | ||
|
|
e3c02f94f1 | ||
|
|
490dd662d5 | ||
|
|
8d00fc29d1 | ||
|
|
784378a999 | ||
|
|
f04f48f593 | ||
|
|
721e9bc464 | ||
|
|
76c828a1c9 | ||
|
|
7e5363f911 | ||
|
|
0d502d4dd4 | ||
|
|
f5b16394f9 | ||
|
|
7132465945 | ||
|
|
a873a2573a | ||
|
|
ff24db6561 | ||
|
|
da26954c3b | ||
|
|
6991cf60e5 | ||
|
|
818aedf04f | ||
|
|
1cb6834207 | ||
|
|
8577929bd4 | ||
|
|
f44121e06b | ||
|
|
faf9fb75c5 | ||
|
|
97d8047a3d | ||
|
|
16220d0a27 | ||
|
|
8980d3716b | ||
|
|
764ec5f8f9 | ||
|
|
a7a7551dae | ||
|
|
571536a7ec | ||
|
|
20e56fff6a | ||
|
|
8f132ca14d | ||
|
|
99c002dac1 | ||
|
|
0cd30ccdec | ||
|
|
acd69276a5 | ||
|
|
faf5878bdf | ||
|
|
f56a540b2f | ||
|
|
e251e5f8f6 | ||
|
|
5a55798d2d | ||
|
|
c9e41ba72a | ||
|
|
522f2b9e26 | ||
|
|
be9267ddd4 | ||
|
|
e4a79b51c7 | ||
|
|
47a9a963fa | ||
|
|
f3c7a831a1 | ||
|
|
6ac9310e81 | ||
|
|
b91e65be0e | ||
|
|
3f2358e30c | ||
|
|
ce02d90c3c | ||
|
|
95a71bea6e | ||
|
|
3b27120d77 | ||
|
|
f350163056 | ||
|
|
57cfecb7b8 | ||
|
|
51fba24a3d | ||
|
|
52f43a600c | ||
|
|
e25174ff97 | ||
|
|
5f8a08b0a7 |
184
client/src/App/App.container.backup-2026-03-04.jsx
Normal file
184
client/src/App/App.container.backup-2026-03-04.jsx
Normal 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);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ApolloProvider } from "@apollo/client/react";
|
import { ApolloProvider } from "@apollo/client/react";
|
||||||
import * as Sentry from "@sentry/react";
|
import * as Sentry from "@sentry/react";
|
||||||
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-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 enLocale from "antd/es/locale/en_US";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { CookiesProvider } from "react-cookie";
|
import { CookiesProvider } from "react-cookie";
|
||||||
@@ -43,47 +43,10 @@ function AppContainer() {
|
|||||||
|
|
||||||
const currentUser = useSelector(selectCurrentUser);
|
const currentUser = useSelector(selectCurrentUser);
|
||||||
const isDarkMode = useSelector(selectDarkMode);
|
const isDarkMode = useSelector(selectDarkMode);
|
||||||
const screens = Grid.useBreakpoint();
|
|
||||||
const isPhone = !screens.md;
|
|
||||||
const isUltraWide = Boolean(screens.xxxl);
|
|
||||||
|
|
||||||
const theme = useMemo(() => {
|
const theme = useMemo(() => getTheme(isDarkMode), [isDarkMode]);
|
||||||
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 antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
|
||||||
const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []);
|
|
||||||
const antdPagination = useMemo(
|
|
||||||
() => ({
|
|
||||||
showSizeChanger: !isPhone,
|
|
||||||
totalBoundaryShowSizeChanger: 100
|
|
||||||
}),
|
|
||||||
[isPhone]
|
|
||||||
);
|
|
||||||
|
|
||||||
const antdForm = useMemo(
|
const antdForm = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -159,16 +122,7 @@ function AppContainer() {
|
|||||||
return (
|
return (
|
||||||
<CookiesProvider>
|
<CookiesProvider>
|
||||||
<ApolloProvider client={client}>
|
<ApolloProvider client={client}>
|
||||||
<ConfigProvider
|
<ConfigProvider input={antdInput} locale={enLocale} theme={theme} form={antdForm}>
|
||||||
input={antdInput}
|
|
||||||
locale={enLocale}
|
|
||||||
theme={theme}
|
|
||||||
form={antdForm}
|
|
||||||
table={antdTable}
|
|
||||||
pagination={antdPagination}
|
|
||||||
componentSize={isPhone ? "small" : isUltraWide ? "large" : "middle"}
|
|
||||||
popupOverflow="viewport"
|
|
||||||
>
|
|
||||||
<GlobalLoadingBar />
|
<GlobalLoadingBar />
|
||||||
<SplitFactoryProvider config={config}>
|
<SplitFactoryProvider config={config}>
|
||||||
<SplitClientProvider>
|
<SplitClientProvider>
|
||||||
|
|||||||
184
client/src/App/App.container.pre-rollback-2026-03-04.jsx
Normal file
184
client/src/App/App.container.pre-rollback-2026-03-04.jsx
Normal 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);
|
||||||
@@ -443,38 +443,62 @@
|
|||||||
flex-direction: column;
|
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 {
|
//.rbc-time-header-gutter {
|
||||||
// padding: 0;
|
// padding: 0;
|
||||||
//}
|
//}
|
||||||
|
|
||||||
/* globally allow shrink inside table cells */
|
///* globally allow shrink inside table cells */
|
||||||
.prod-list-table .ant-table-cell,
|
//.prod-list-table .ant-table-cell,
|
||||||
.prod-list-table .ant-table-cell > * {
|
//.prod-list-table .ant-table-cell > * {
|
||||||
min-width: 0;
|
// min-width: 0;
|
||||||
}
|
//}
|
||||||
|
//
|
||||||
/* common AntD offenders */
|
///* common AntD offenders */
|
||||||
.prod-list-table > .ant-table-cell .ant-space,
|
//.prod-list-table > .ant-table-cell .ant-space,
|
||||||
.ant-table-cell .ant-space-item {
|
//.ant-table-cell .ant-space-item {
|
||||||
min-width: 0;
|
// min-width: 0;
|
||||||
}
|
//}
|
||||||
|
//
|
||||||
/* Keep your custom header content on the left, push AntD sorter to the far right */
|
///* Keep your custom header content on the left, push AntD sorter to the far right */
|
||||||
.prod-list-table .ant-table-column-sorters {
|
//.prod-list-table .ant-table-column-sorters {
|
||||||
display: flex !important;
|
// display: flex !important;
|
||||||
align-items: center;
|
// align-items: center;
|
||||||
width: 100%;
|
// width: 100%;
|
||||||
}
|
//}
|
||||||
|
//
|
||||||
.prod-list-table .ant-table-column-title {
|
//.prod-list-table .ant-table-column-title {
|
||||||
flex: 1 1 auto;
|
// flex: 1 1 auto;
|
||||||
min-width: 0; /* allows ellipsis to work */
|
// min-width: 0; /* allows ellipsis to work */
|
||||||
}
|
//}
|
||||||
|
//
|
||||||
.prod-list-table .ant-table-column-sorter {
|
//.prod-list-table .ant-table-column-sorter {
|
||||||
margin-left: auto;
|
// margin-left: auto;
|
||||||
flex: 0 0 auto;
|
// flex: 0 0 auto;
|
||||||
}
|
//}
|
||||||
|
|
||||||
|
|
||||||
.global-search-autocomplete-fix {
|
.global-search-autocomplete-fix {
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ function BillEnterAiScan({
|
|||||||
setIsAiScan(true);
|
setIsAiScan(true);
|
||||||
const formdata = new FormData();
|
const formdata = new FormData();
|
||||||
formdata.append("billScan", file);
|
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("bodyshopid", bodyshop.id);
|
||||||
formdata.append("partsorderid", billEnterModal.context.parts_order?.id);
|
formdata.append("partsorderid", billEnterModal.context.parts_order?.id);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import dayjs from "../../utils/day";
|
|||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectDarkMode } from "../../redux/application/application.selectors.js";
|
import { selectDarkMode } from "../../redux/application/application.selectors.js";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
isDarkMode: selectDarkMode
|
isDarkMode: selectDarkMode
|
||||||
@@ -19,25 +20,35 @@ export function DmsLogEvents({
|
|||||||
detailsNonce,
|
detailsNonce,
|
||||||
isDarkMode,
|
isDarkMode,
|
||||||
colorizeJson = false,
|
colorizeJson = false,
|
||||||
showDetails = true
|
showDetails = true,
|
||||||
|
allowXmlPayload = true
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [openSet, setOpenSet] = useState(() => new Set());
|
const [openSet, setOpenSet] = useState(() => new Set());
|
||||||
|
const [copiedKey, setCopiedKey] = useState(null);
|
||||||
|
|
||||||
// Inject JSON highlight styles once (only when colorize is enabled)
|
// Inject JSON highlight styles once (only when colorize is enabled)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!colorizeJson) return;
|
if (!colorizeJson) return;
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
if (document.getElementById("json-highlight-styles")) return;
|
let style = document.getElementById("json-highlight-styles");
|
||||||
const style = document.createElement("style");
|
if (!style) {
|
||||||
style.id = "json-highlight-styles";
|
style = document.createElement("style");
|
||||||
|
style.id = "json-highlight-styles";
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
style.textContent = `
|
style.textContent = `
|
||||||
.json-key { color: #fa8c16; }
|
.json-key { color: #fa8c16; }
|
||||||
.json-string { color: #52c41a; }
|
.json-string { color: #52c41a; }
|
||||||
.json-number { color: #722ed1; }
|
.json-number { color: #722ed1; }
|
||||||
.json-boolean { color: #1890ff; }
|
.json-boolean { color: #1890ff; }
|
||||||
.json-null { color: #faad14; }
|
.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]);
|
}, [colorizeJson]);
|
||||||
|
|
||||||
// Trim openSet if logs shrink
|
// Trim openSet if logs shrink
|
||||||
@@ -65,6 +76,13 @@ export function DmsLogEvents({
|
|||||||
// Only treat meta as "present" when we are allowed to show details
|
// Only treat meta as "present" when we are allowed to show details
|
||||||
const hasMeta = !isEmpty(meta) && showDetails;
|
const hasMeta = !isEmpty(meta) && showDetails;
|
||||||
const isOpen = hasMeta && openSet.has(idx);
|
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 {
|
return {
|
||||||
key: idx,
|
key: idx,
|
||||||
@@ -92,10 +110,42 @@ export function DmsLogEvents({
|
|||||||
return next;
|
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>
|
</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>
|
</Space>
|
||||||
@@ -103,14 +153,30 @@ export function DmsLogEvents({
|
|||||||
{/* Row 2: details body (only when open) */}
|
{/* Row 2: details body (only when open) */}
|
||||||
{hasMeta && isOpen && (
|
{hasMeta && isOpen && (
|
||||||
<div style={{ marginLeft: 6 }}>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
[logs, openSet, colorizeJson, isDarkMode, showDetails]
|
[logs, openSet, colorizeJson, copiedKey, isDarkMode, showDetails, allowXmlPayload, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <Timeline reverse items={items} />;
|
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.
|
* JSON display block with optional syntax highlighting.
|
||||||
* @param data
|
* @param data
|
||||||
@@ -210,6 +391,105 @@ const JsonBlock = ({ data, colorize, isDarkMode }) => {
|
|||||||
return <pre style={preStyle}>{jsonText}</pre>;
|
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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
const lines = esc.split("\n");
|
||||||
|
|
||||||
|
return lines
|
||||||
|
.map((line) => {
|
||||||
|
let out = line;
|
||||||
|
|
||||||
|
out = out.replace(/(<!--[\s\S]*?-->)/g, '<span class="xml-comment">$1</span>');
|
||||||
|
out = out.replace(/(<\?xml[\s\S]*?\?>)/g, '<span class="xml-decl">$1</span>');
|
||||||
|
|
||||||
|
out = out.replace(/(<\/?)([A-Za-z_][\w:.-]*)([\s\S]*?)(\/?>)/g, (_m, open, tag, attrs, close) => {
|
||||||
|
const coloredAttrs = attrs.replace(
|
||||||
|
/([A-Za-z_][\w:.-]*)(=)("[^"]*"|'[^']*'|"[\s\S]*?"|'[\s\S]*?')/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.
|
* Syntax highlight JSON text for HTML display.
|
||||||
* @param jsonText
|
* @param jsonText
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function EmailOverlayComponent({ emailConfig, form, selectedMediaState, b
|
|||||||
const emailsToMenu = {
|
const emailsToMenu = {
|
||||||
items: [
|
items: [
|
||||||
...bodyshop.employees
|
...bodyshop.employees
|
||||||
.filter((e) => e.user_email)
|
.filter((e) => e.user_email && e.active === true)
|
||||||
.map((e, idx) => ({
|
.map((e, idx) => ({
|
||||||
key: idx,
|
key: idx,
|
||||||
label: `${e.first_name} ${e.last_name}`,
|
label: `${e.first_name} ${e.last_name}`,
|
||||||
@@ -59,7 +59,7 @@ export function EmailOverlayComponent({ emailConfig, form, selectedMediaState, b
|
|||||||
const menuCC = {
|
const menuCC = {
|
||||||
items: [
|
items: [
|
||||||
...bodyshop.employees
|
...bodyshop.employees
|
||||||
.filter((e) => e.user_email)
|
.filter((e) => e.user_email && e.active === true)
|
||||||
.map((e, idx) => ({
|
.map((e, idx) => ({
|
||||||
key: idx,
|
key: idx,
|
||||||
label: `${e.first_name} ${e.last_name}`,
|
label: `${e.first_name} ${e.last_name}`,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const Eula = ({ currentEula, currentUser, acceptEula }) => {
|
|||||||
|
|
||||||
const handleScroll = useCallback(
|
const handleScroll = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
|
if (!e.target) return;
|
||||||
const bottom = e.target.scrollHeight - 100 <= e.target.scrollTop + e.target.clientHeight;
|
const bottom = e.target.scrollHeight - 100 <= e.target.scrollTop + e.target.clientHeight;
|
||||||
if (bottom && !hasEverScrolledToBottom) {
|
if (bottom && !hasEverScrolledToBottom) {
|
||||||
setHasEverScrolledToBottom(true);
|
setHasEverScrolledToBottom(true);
|
||||||
@@ -36,7 +37,9 @@ const Eula = ({ currentEula, currentUser, acceptEula }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleScroll({ target: markdownCardRef.current });
|
if (markdownCardRef.current) {
|
||||||
|
handleScroll({ target: markdownCardRef.current });
|
||||||
|
}
|
||||||
}, [handleScroll]);
|
}, [handleScroll]);
|
||||||
|
|
||||||
const handleChange = useCallback(() => {
|
const handleChange = useCallback(() => {
|
||||||
|
|||||||
@@ -10,8 +10,13 @@ const mapDispatchToProps = () => ({
|
|||||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const toFiniteNumber = (value) => {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
|
const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
|
||||||
if (!value) return null;
|
if (value === null || value === undefined || value === "") return null;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "employee": {
|
case "employee": {
|
||||||
const emp = bodyshop.employees.find((e) => e.id === value);
|
const emp = bodyshop.employees.find((e) => e.id === value);
|
||||||
@@ -20,8 +25,15 @@ const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
|
|||||||
|
|
||||||
case "text":
|
case "text":
|
||||||
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
|
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
|
||||||
case "currency":
|
case "currency": {
|
||||||
return <div>{Dinero({ amount: Math.round(value * 100) }).toFormat()}</div>;
|
const numericValue = toFiniteNumber(value);
|
||||||
|
|
||||||
|
if (numericValue === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>{Dinero({ amount: Math.round(numericValue * 100) }).toFormat()}</div>;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
|
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { DownOutlined, UpOutlined } from "@ant-design/icons";
|
import { DownOutlined, UpOutlined } from "@ant-design/icons";
|
||||||
import { Space } from "antd";
|
import { Space } from "antd";
|
||||||
|
|
||||||
export default function FormListMoveArrows({ move, index, total }) {
|
export default function FormListMoveArrows({ move, index, total, orientation = "vertical" }) {
|
||||||
const upDisabled = index === 0;
|
const upDisabled = index === 0;
|
||||||
const downDisabled = index === total - 1;
|
const downDisabled = index === total - 1;
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export default function FormListMoveArrows({ move, index, total }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space orientation="vertical">
|
<Space orientation={orientation}>
|
||||||
<UpOutlined disabled={upDisabled} onClick={handleUp} />
|
<UpOutlined disabled={upDisabled} onClick={handleUp} />
|
||||||
<DownOutlined disabled={downDisabled} onClick={handleDown} />
|
<DownOutlined disabled={downDisabled} onClick={handleDown} />
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import JobLinesBillRefernece from "../job-lines-bill-reference/job-lines-bill-re
|
|||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { FaTasks } from "react-icons/fa";
|
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 dayjs from "../../utils/day";
|
||||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
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 JobLinesExpanderSimple from "./jobs-lines-expander-simple.component";
|
||||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component.jsx";
|
||||||
|
|
||||||
const UPDATE_JOB_LINES_LOCATION_BULK = gql`
|
const UPDATE_JOB_LINES_LOCATION_BULK = gql`
|
||||||
mutation UPDATE_JOB_LINES_LOCATION_BULK($ids: [uuid!]!, $location: String!) {
|
mutation UPDATE_JOB_LINES_LOCATION_BULK($ids: [uuid!]!, $location: String!) {
|
||||||
@@ -66,7 +67,8 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
bodyshop: selectBodyshop,
|
bodyshop: selectBodyshop,
|
||||||
jobRO: selectJobReadOnly,
|
jobRO: selectJobReadOnly,
|
||||||
technician: selectTechnician,
|
technician: selectTechnician,
|
||||||
isPartsEntry: selectIsPartsEntry
|
isPartsEntry: selectIsPartsEntry,
|
||||||
|
authLevel: selectAuthLevel
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
@@ -94,7 +96,8 @@ export function JobLinesComponent({
|
|||||||
setTaskUpsertContext,
|
setTaskUpsertContext,
|
||||||
billsQuery,
|
billsQuery,
|
||||||
handlePartsOrderOnRowClick,
|
handlePartsOrderOnRowClick,
|
||||||
isPartsEntry
|
isPartsEntry,
|
||||||
|
authLevel
|
||||||
}) {
|
}) {
|
||||||
const [deleteJobLine] = useMutation(DELETE_JOB_LINE_BY_PK);
|
const [deleteJobLine] = useMutation(DELETE_JOB_LINE_BY_PK);
|
||||||
const [bulkUpdateLocations] = useMutation(UPDATE_JOB_LINES_LOCATION_BULK);
|
const [bulkUpdateLocations] = useMutation(UPDATE_JOB_LINES_LOCATION_BULK);
|
||||||
@@ -386,18 +389,20 @@ export function JobLinesComponent({
|
|||||||
key: "actions",
|
key: "actions",
|
||||||
render: (text, record) => (
|
render: (text, record) => (
|
||||||
<Space>
|
<Space>
|
||||||
{(record.manual_line || jobIsPrivate) && !technician && (
|
{(record.manual_line || jobIsPrivate) &&
|
||||||
<Button
|
!technician &&
|
||||||
disabled={jobRO}
|
HasRbacAccess({ bodyshop, authLevel, action: "jobs:manual-line" }) && (
|
||||||
onClick={() => {
|
<Button
|
||||||
setJobLineEditContext({
|
disabled={jobRO}
|
||||||
actions: { refetch: refetch, submit: form && form.submit },
|
onClick={() => {
|
||||||
context: { ...record, jobid: job.id }
|
setJobLineEditContext({
|
||||||
});
|
actions: { refetch: refetch, submit: form && form.submit },
|
||||||
}}
|
context: { ...record, jobid: job.id }
|
||||||
icon={<EditFilled />}
|
});
|
||||||
/>
|
}}
|
||||||
)}
|
icon={<EditFilled />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
title={t("tasks.buttons.create")}
|
title={t("tasks.buttons.create")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -410,29 +415,30 @@ export function JobLinesComponent({
|
|||||||
}}
|
}}
|
||||||
icon={<FaTasks />}
|
icon={<FaTasks />}
|
||||||
/>
|
/>
|
||||||
|
{(record.manual_line || jobIsPrivate) &&
|
||||||
{(record.manual_line || jobIsPrivate) && !technician && (
|
!technician &&
|
||||||
<Button
|
HasRbacAccess({ bodyshop, authLevel, action: "jobs:manual-line" }) && (
|
||||||
disabled={jobRO}
|
<Button
|
||||||
onClick={async () => {
|
disabled={jobRO}
|
||||||
await deleteJobLine({
|
onClick={async () => {
|
||||||
variables: { joblineId: record.id },
|
await deleteJobLine({
|
||||||
update(cache) {
|
variables: { joblineId: record.id },
|
||||||
cache.modify({
|
update(cache) {
|
||||||
fields: {
|
cache.modify({
|
||||||
joblines(existingJobLines, { readField }) {
|
fields: {
|
||||||
return existingJobLines.filter((jlRef) => record.id !== readField("id", jlRef));
|
joblines(existingJobLines, { readField }) {
|
||||||
|
return existingJobLines.filter((jlRef) => record.id !== readField("id", jlRef));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
});
|
await axios.post("/job/totalsssu", { id: job.id });
|
||||||
await axios.post("/job/totalsssu", { id: job.id });
|
if (refetch) refetch();
|
||||||
if (refetch) refetch();
|
}}
|
||||||
}}
|
icon={<DeleteFilled />}
|
||||||
icon={<DeleteFilled />}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -657,7 +663,7 @@ export function JobLinesComponent({
|
|||||||
<Button id="repair-data-mark-button">{t("jobs.actions.mark")}</Button>
|
<Button id="repair-data-mark-button">{t("jobs.actions.mark")}</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|
||||||
{!isPartsEntry && (
|
{!isPartsEntry && HasRbacAccess({ bodyshop, authLevel, action: "jobs:manual-line" }) && (
|
||||||
<Button
|
<Button
|
||||||
disabled={jobRO || technician}
|
disabled={jobRO || technician}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -144,18 +144,11 @@ export default function JobTotalsTableLabor({ job }) {
|
|||||||
{t("jobs.labels.mapa")}
|
{t("jobs.labels.mapa")}
|
||||||
{InstanceRenderManager({
|
{InstanceRenderManager({
|
||||||
imex:
|
imex:
|
||||||
job.materials?.mapa &&
|
(job.materials?.mapa ?? job.materials?.MAPA)?.cal_maxdlr > 0 &&
|
||||||
job.materials.mapa.cal_maxdlr &&
|
t("jobs.labels.threshhold", { amount: (job.materials.mapa ?? job.materials.MAPA).cal_maxdlr }),
|
||||||
job.materials.mapa.cal_maxdlr > 0 &&
|
|
||||||
t("jobs.labels.threshhold", {
|
|
||||||
amount: job.materials.mapa.cal_maxdlr
|
|
||||||
}),
|
|
||||||
rome:
|
rome:
|
||||||
job.materials?.MAPA &&
|
job.materials?.MAPA?.cal_maxdlr !== undefined &&
|
||||||
job.materials.MAPA.cal_maxdlr !== undefined &&
|
t("jobs.labels.threshhold", { amount: job.materials.MAPA.cal_maxdlr })
|
||||||
t("jobs.labels.threshhold", {
|
|
||||||
amount: job.materials.MAPA.cal_maxdlr
|
|
||||||
})
|
|
||||||
})}
|
})}
|
||||||
</Space>
|
</Space>
|
||||||
</ResponsiveTable.Summary.Cell>
|
</ResponsiveTable.Summary.Cell>
|
||||||
@@ -190,18 +183,11 @@ export default function JobTotalsTableLabor({ job }) {
|
|||||||
{t("jobs.labels.mash")}
|
{t("jobs.labels.mash")}
|
||||||
{InstanceRenderManager({
|
{InstanceRenderManager({
|
||||||
imex:
|
imex:
|
||||||
job.materials?.mash &&
|
(job.materials?.mash ?? job.materials?.MASH)?.cal_maxdlr > 0 &&
|
||||||
job.materials.mash.cal_maxdlr &&
|
t("jobs.labels.threshhold", { amount: (job.materials.mash ?? job.materials.MASH).cal_maxdlr }),
|
||||||
job.materials.mash.cal_maxdlr > 0 &&
|
|
||||||
t("jobs.labels.threshhold", {
|
|
||||||
amount: job.materials.mash.cal_maxdlr
|
|
||||||
}),
|
|
||||||
rome:
|
rome:
|
||||||
job.materials?.MASH &&
|
job.materials?.MASH?.cal_maxdlr !== undefined &&
|
||||||
job.materials.MASH.cal_maxdlr !== undefined &&
|
t("jobs.labels.threshhold", { amount: job.materials.MASH.cal_maxdlr })
|
||||||
t("jobs.labels.threshhold", {
|
|
||||||
amount: job.materials.MASH.cal_maxdlr
|
|
||||||
})
|
|
||||||
})}
|
})}
|
||||||
</Space>
|
</Space>
|
||||||
</ResponsiveTable.Summary.Cell>
|
</ResponsiveTable.Summary.Cell>
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ export function JobsAdminClass({ bodyshop, job }) {
|
|||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Popconfirm title={t("jobs.labels.changeclass")} onConfirm={() => form.submit()}>
|
<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>
|
</Popconfirm>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export function JobsAdminDatesChange({ insertAuditTrail, job }) {
|
|||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Button loading={loading} onClick={() => form.submit()}>
|
<Button loading={loading} type="primary" onClick={() => form.submit()}>
|
||||||
{t("general.actions.save")}
|
{t("general.actions.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function JobAdminOwnerReassociate({ job }) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<div>{t("jobs.labels.associationwarning")}</div>
|
<div>{t("jobs.labels.associationwarning")}</div>
|
||||||
<Button loading={loading} onClick={() => form.submit()}>
|
<Button loading={loading} type="primary" onClick={() => form.submit()}>
|
||||||
{t("general.actions.save")}
|
{t("general.actions.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function JobAdminOwnerReassociate({ job }) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<div>{t("jobs.labels.associationwarning")}</div>
|
<div>{t("jobs.labels.associationwarning")}</div>
|
||||||
<Button loading={loading} onClick={() => form.submit()}>
|
<Button loading={loading} type="primary" onClick={() => form.submit()}>
|
||||||
{t("general.actions.save")}
|
{t("general.actions.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
|
|||||||
showSearch={{
|
showSearch={{
|
||||||
optionFilterProp: "children",
|
optionFilterProp: "children",
|
||||||
filterOption: (input, option) =>
|
filterOption: (input, option) =>
|
||||||
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
option?.value?.toLowerCase().indexOf(input?.toLowerCase()) >= 0
|
||||||
}}
|
}}
|
||||||
disabled={jobRO}
|
disabled={jobRO}
|
||||||
options={bodyshop.md_responsibility_centers.profits.map((p) => ({
|
options={bodyshop.md_responsibility_centers.profits.map((p) => ({
|
||||||
@@ -166,7 +166,7 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
|
|||||||
showSearch={{
|
showSearch={{
|
||||||
optionFilterProp: "children",
|
optionFilterProp: "children",
|
||||||
filterOption: (input, option) =>
|
filterOption: (input, option) =>
|
||||||
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
option?.value?.toLowerCase().indexOf(input?.toLowerCase()) >= 0
|
||||||
}}
|
}}
|
||||||
disabled={jobRO}
|
disabled={jobRO}
|
||||||
options={bodyshop.md_responsibility_centers.profits.map((p) => ({
|
options={bodyshop.md_responsibility_centers.profits.map((p) => ({
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
technician: selectTechnician
|
technician: selectTechnician
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getRequestErrorMessage = (error) => error?.response?.data?.error || error?.message || "";
|
||||||
|
|
||||||
export function PayrollLaborAllocationsTable({
|
export function PayrollLaborAllocationsTable({
|
||||||
jobId,
|
jobId,
|
||||||
joblines,
|
joblines,
|
||||||
@@ -43,16 +45,23 @@ export function PayrollLaborAllocationsTable({
|
|||||||
});
|
});
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
useEffect(() => {
|
const loadTotals = async () => {
|
||||||
async function CalculateTotals() {
|
try {
|
||||||
const { data } = await axios.post("/payroll/calculatelabor", {
|
const { data } = await axios.post("/payroll/calculatelabor", {
|
||||||
jobid: jobId
|
jobid: jobId
|
||||||
});
|
});
|
||||||
setTotals(data);
|
setTotals(data);
|
||||||
|
} catch (error) {
|
||||||
|
setTotals([]);
|
||||||
|
notification.error({
|
||||||
|
title: getRequestErrorMessage(error)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (!!joblines && !!timetickets && !!bodyshop) {
|
if (!!joblines && !!timetickets && !!bodyshop) {
|
||||||
CalculateTotals();
|
loadTotals();
|
||||||
}
|
}
|
||||||
if (!jobId) setTotals([]);
|
if (!jobId) setTotals([]);
|
||||||
}, [joblines, timetickets, bodyshop, adjustments, jobId]);
|
}, [joblines, timetickets, bodyshop, adjustments, jobId]);
|
||||||
@@ -210,28 +219,36 @@ export function PayrollLaborAllocationsTable({
|
|||||||
<Button
|
<Button
|
||||||
disabled={!hasTimeTicketAccess}
|
disabled={!hasTimeTicketAccess}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const response = await axios.post("/payroll/payall", {
|
try {
|
||||||
jobid: jobId
|
const response = await axios.post("/payroll/payall", {
|
||||||
});
|
jobid: jobId
|
||||||
|
});
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
if (response.data.success !== false) {
|
if (response.data.success !== false) {
|
||||||
notification.success({
|
notification.success({
|
||||||
title: t("timetickets.successes.payall")
|
title: t("timetickets.successes.payall")
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
notification.error({
|
||||||
|
title: t("timetickets.errors.payall", {
|
||||||
|
error: response.data.error
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refetch) refetch();
|
||||||
} else {
|
} else {
|
||||||
notification.error({
|
notification.error({
|
||||||
title: t("timetickets.errors.payall", {
|
title: t("timetickets.errors.payall", {
|
||||||
error: response.data.error
|
error: JSON.stringify("")
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
if (refetch) refetch();
|
|
||||||
} else {
|
|
||||||
notification.error({
|
notification.error({
|
||||||
title: t("timetickets.errors.payall", {
|
title: t("timetickets.errors.payall", {
|
||||||
error: JSON.stringify("")
|
error: getRequestErrorMessage(error)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -241,10 +258,7 @@ export function PayrollLaborAllocationsTable({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const { data } = await axios.post("/payroll/calculatelabor", {
|
await loadTotals();
|
||||||
jobid: jobId
|
|
||||||
});
|
|
||||||
setTotals(data);
|
|
||||||
refetch();
|
refetch();
|
||||||
}}
|
}}
|
||||||
icon={<SyncOutlined />}
|
icon={<SyncOutlined />}
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ export function PartsOrderListTableComponent({
|
|||||||
const [deletePartsOrder] = useMutation(DELETE_PARTS_ORDER);
|
const [deletePartsOrder] = useMutation(DELETE_PARTS_ORDER);
|
||||||
|
|
||||||
const parts_orders = billsQuery.data ? billsQuery.data.parts_orders : [];
|
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 { refetch } = billsQuery;
|
||||||
|
|
||||||
const recordActions = (record, showView = false) => (
|
const recordActions = (record, showView = false) => (
|
||||||
@@ -222,7 +228,12 @@ export function PartsOrderListTableComponent({
|
|||||||
dataIndex: "order_number",
|
dataIndex: "order_number",
|
||||||
key: "order_number",
|
key: "order_number",
|
||||||
sorter: (a, b) => alphaSort(a.invoice_number, b.invoice_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"),
|
title: t("parts_orders.fields.order_date"),
|
||||||
@@ -272,10 +283,10 @@ export function PartsOrderListTableComponent({
|
|||||||
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
|
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredPartsOrders = parts_orders
|
const filteredPartsOrders = enrichedPartsOrders
|
||||||
? searchText === ""
|
? searchText === ""
|
||||||
? parts_orders
|
? enrichedPartsOrders
|
||||||
: parts_orders.filter(
|
: enrichedPartsOrders.filter(
|
||||||
(b) =>
|
(b) =>
|
||||||
(b.order_number || "").toString().toLowerCase().includes(searchText.toLowerCase()) ||
|
(b.order_number || "").toString().toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
(b.vendor.name || "").toLowerCase().includes(searchText.toLowerCase())
|
(b.vendor.name || "").toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Icon from "@ant-design/icons";
|
import Icon from "@ant-design/icons";
|
||||||
import { useMutation } from "@apollo/client/react";
|
import { useMutation } from "@apollo/client/react";
|
||||||
import { Button, Input, Popover, Tooltip } from "antd";
|
import { Button, Input, Popover, Tooltip } from "antd";
|
||||||
import { useState } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FaRegStickyNote } from "react-icons/fa";
|
import { FaRegStickyNote } from "react-icons/fa";
|
||||||
import { UPDATE_JOB } from "../../graphql/jobs.queries";
|
import { UPDATE_JOB } from "../../graphql/jobs.queries";
|
||||||
@@ -9,10 +9,10 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
|
|||||||
|
|
||||||
export default function ProductionListColumnComment({ record, usePortal = false }) {
|
export default function ProductionListColumnComment({ record, usePortal = false }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [note, setNote] = useState(record.comment || "");
|
const [note, setNote] = useState(record.comment || "");
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const textAreaRef = useRef(null);
|
||||||
|
const rafIdRef = useRef(null);
|
||||||
|
|
||||||
const [updateAlert] = useMutation(UPDATE_JOB);
|
const [updateAlert] = useMutation(UPDATE_JOB);
|
||||||
|
|
||||||
@@ -38,23 +38,35 @@ export default function ProductionListColumnComment({ record, usePortal = false
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (flag) => {
|
const handleOpenChange = (flag) => {
|
||||||
|
if (rafIdRef.current) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
|
rafIdRef.current = null;
|
||||||
|
}
|
||||||
setOpen(flag);
|
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 = (
|
const content = (
|
||||||
<div
|
<div style={{ width: "30em" }} onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>
|
||||||
style={{ width: "30em" }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
id={`job-comment-${record.id}`}
|
id={`job-comment-${record.id}`}
|
||||||
name="comment"
|
name="comment"
|
||||||
rows={5}
|
rows={5}
|
||||||
value={note}
|
value={note}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
autoFocus
|
ref={textAreaRef}
|
||||||
allowClear
|
allowClear
|
||||||
style={{ marginBottom: "1em" }}
|
style={{ marginBottom: "1em" }}
|
||||||
/>
|
/>
|
||||||
@@ -67,13 +79,13 @@ export default function ProductionListColumnComment({ record, usePortal = false
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
open={open}
|
open={open}
|
||||||
content={content}
|
content={content}
|
||||||
trigger="click"
|
trigger="click"
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
styles={{ body: { padding: '12px' } }}
|
styles={{ body: { padding: "12px" } }}
|
||||||
{...(usePortal ? { getPopupContainer: (trigger) => trigger.parentElement || document.body } : {})}
|
{...(usePortal ? { getPopupContainer: (trigger) => trigger.parentElement || document.body } : {})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Icon from "@ant-design/icons";
|
import Icon from "@ant-design/icons";
|
||||||
import { useMutation } from "@apollo/client/react";
|
import { useMutation } from "@apollo/client/react";
|
||||||
import { Button, Input, Popover, Space } from "antd";
|
import { Button, Input, Popover, Space } from "antd";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FaRegStickyNote } from "react-icons/fa";
|
import { FaRegStickyNote } from "react-icons/fa";
|
||||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
@@ -20,6 +20,8 @@ function ProductionListColumnProductionNote({ record, setNoteUpsertContext, useP
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [note, setNote] = useState(record.production_vars?.note || "");
|
const [note, setNote] = useState(record.production_vars?.note || "");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const textAreaRef = useRef(null);
|
||||||
|
const rafIdRef = useRef(null);
|
||||||
|
|
||||||
const [updateAlert] = useMutation(UPDATE_JOB);
|
const [updateAlert] = useMutation(UPDATE_JOB);
|
||||||
|
|
||||||
@@ -52,25 +54,37 @@ function ProductionListColumnProductionNote({ record, setNoteUpsertContext, useP
|
|||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(flag) => {
|
(flag) => {
|
||||||
|
if (rafIdRef.current) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
|
rafIdRef.current = null;
|
||||||
|
}
|
||||||
setOpen(flag);
|
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]
|
[record]
|
||||||
);
|
);
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<div
|
<div style={{ width: "30em" }} onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>
|
||||||
style={{ width: "30em" }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
id={`job-production-note-${record.id}`}
|
id={`job-production-note-${record.id}`}
|
||||||
name="production_note"
|
name="production_note"
|
||||||
rows={5}
|
rows={5}
|
||||||
value={note}
|
value={note}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
autoFocus
|
ref={textAreaRef}
|
||||||
allowClear
|
allowClear
|
||||||
style={{ marginBottom: "1em" }}
|
style={{ marginBottom: "1em" }}
|
||||||
/>
|
/>
|
||||||
@@ -96,13 +110,13 @@ function ProductionListColumnProductionNote({ record, setNoteUpsertContext, useP
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
open={open}
|
open={open}
|
||||||
content={content}
|
content={content}
|
||||||
trigger="click"
|
trigger="click"
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
styles={{ body: { padding: '12px' } }}
|
styles={{ body: { padding: "12px" } }}
|
||||||
{...(usePortal ? { getPopupContainer: (trigger) => trigger.parentElement || document.body } : {})}
|
{...(usePortal ? { getPopupContainer: (trigger) => trigger.parentElement || document.body } : {})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const ret = {
|
|||||||
"jobs:partsqueue": 4,
|
"jobs:partsqueue": 4,
|
||||||
"jobs:checklist-view": 2,
|
"jobs:checklist-view": 2,
|
||||||
"jobs:list-ready": 1,
|
"jobs:list-ready": 1,
|
||||||
|
"jobs:manual-line": 1,
|
||||||
"jobs:void": 5,
|
"jobs:void": 5,
|
||||||
|
|
||||||
"bills:enter": 2,
|
"bills:enter": 2,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,93 +1,7 @@
|
|||||||
import { Grid, Table } from "antd";
|
import { Table } from "antd";
|
||||||
import { useMemo } from "react";
|
|
||||||
import "./responsive-table.styles.scss";
|
|
||||||
|
|
||||||
function ResponsiveTable({ className, columns, mobileColumnKeys, scroll, tableLayout, ...rest }) {
|
function ResponsiveTable(props) {
|
||||||
const screens = Grid.useBreakpoint();
|
return <Table {...props} />;
|
||||||
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.Summary = Table.Summary;
|
||||||
|
|||||||
@@ -435,6 +435,19 @@ export function ShopInfoRbacComponent({ bodyshop }) {
|
|||||||
>
|
>
|
||||||
<InputNumber />
|
<InputNumber />
|
||||||
</Form.Item>,
|
</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
|
<Form.Item
|
||||||
key="jobs:partsqueue"
|
key="jobs:partsqueue"
|
||||||
label={t("bodyshop.fields.rbac.jobs.partsqueue")}
|
label={t("bodyshop.fields.rbac.jobs.partsqueue")}
|
||||||
|
|||||||
@@ -16,6 +16,43 @@ const mapDispatchToProps = () => ({
|
|||||||
});
|
});
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoTaskPresets);
|
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 }) {
|
export function ShopInfoTaskPresets({ bodyshop }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -39,8 +76,21 @@ export function ShopInfoTaskPresets({ bodyshop }) {
|
|||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
|
|
||||||
<LayoutFormRow header={t("bodyshop.labels.md_tasks_presets")}>
|
<LayoutFormRow header={t("bodyshop.labels.md_tasks_presets")}>
|
||||||
<Form.List name={["md_tasks_presets", "presets"]}>
|
<Form.List
|
||||||
{(fields, { add, remove, move }) => {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => (
|
||||||
@@ -189,6 +239,7 @@ export function ShopInfoTaskPresets({ bodyshop }) {
|
|||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
))}
|
))}
|
||||||
|
<Form.ErrorList errors={errors} />
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button
|
<Button
|
||||||
type="dashed"
|
type="dashed"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DeleteFilled } from "@ant-design/icons";
|
import { DeleteFilled } from "@ant-design/icons";
|
||||||
import { useMutation, useQuery } from "@apollo/client/react";
|
import { useMutation, useQuery } from "@apollo/client/react";
|
||||||
import { Button, Card, Form, Input, InputNumber, Space, Switch } from "antd";
|
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch, Tag, Typography } from "antd";
|
||||||
|
|
||||||
import querystring from "query-string";
|
import querystring from "query-string";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -26,10 +26,59 @@ import { useNotification } from "../../contexts/Notifications/notificationContex
|
|||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
});
|
});
|
||||||
const mapDispatchToProps = () => ({
|
const mapDispatchToProps = () => ({});
|
||||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
|
||||||
|
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 TEAM_MEMBER_PRIMARY_FIELD_COLS = {
|
||||||
|
employee: { xs: 24, lg: 13, xxl: 14 },
|
||||||
|
allocation: { xs: 24, sm: 12, lg: 4, xxl: 4 },
|
||||||
|
payoutMethod: { xs: 24, sm: 12, lg: 7, xxl: 6 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEAM_MEMBER_RATE_FIELD_COLS = { xs: 24, sm: 12, md: 8, lg: 6, xxl: 4 };
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const getPayoutMethodTagColor = (payoutMethod) => (payoutMethod === "commission" ? "gold" : "blue");
|
||||||
|
|
||||||
|
const getEmployeeDisplayName = (employees = [], employeeId) => {
|
||||||
|
const employee = employees.find((currentEmployee) => currentEmployee.id === employeeId);
|
||||||
|
if (!employee) return null;
|
||||||
|
|
||||||
|
const fullName = [employee.first_name, employee.last_name].filter(Boolean).join(" ").trim();
|
||||||
|
return fullName || employee.employee_number || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAllocationPercentage = (percentage) => {
|
||||||
|
if (percentage === null || percentage === undefined || percentage === "") return null;
|
||||||
|
|
||||||
|
const numericValue = Number(percentage);
|
||||||
|
if (!Number.isFinite(numericValue)) return null;
|
||||||
|
|
||||||
|
return `${numericValue.toFixed(2).replace(/\.?0+$/, "")}%`;
|
||||||
|
};
|
||||||
|
|
||||||
export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
@@ -45,38 +94,100 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.employee_teams_by_pk) form.setFieldsValue(data.employee_teams_by_pk);
|
if (data?.employee_teams_by_pk) {
|
||||||
else {
|
form.setFieldsValue(normalizeEmployeeTeam(data.employee_teams_by_pk));
|
||||||
|
} else {
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
}
|
}
|
||||||
}, [form, data, search.employeeTeamId]);
|
}, [form, data, search.employeeTeamId]);
|
||||||
|
|
||||||
const [updateEmployeeTeam] = useMutation(UPDATE_EMPLOYEE_TEAM);
|
const [updateEmployeeTeam] = useMutation(UPDATE_EMPLOYEE_TEAM);
|
||||||
const [insertEmployeeTeam] = useMutation(INSERT_EMPLOYEE_TEAM);
|
const [insertEmployeeTeam] = useMutation(INSERT_EMPLOYEE_TEAM);
|
||||||
|
const payoutMethodOptions = PAYOUT_METHOD_OPTIONS.map(({ labelKey, value }) => ({
|
||||||
|
label: t(labelKey),
|
||||||
|
value
|
||||||
|
}));
|
||||||
|
const teamName = Form.useWatch("name", form);
|
||||||
|
const teamMembers = Form.useWatch(["employee_team_members"], form) || [];
|
||||||
|
const teamCardTitle = teamName?.trim() || t("employee_teams.fields.name");
|
||||||
|
|
||||||
|
const getTeamMemberTitle = (teamMember = {}) => {
|
||||||
|
const employeeName =
|
||||||
|
getEmployeeDisplayName(bodyshop.employees, teamMember.employeeid) || t("employee_teams.fields.employeeid");
|
||||||
|
const allocation = formatAllocationPercentage(teamMember.percentage);
|
||||||
|
const payoutMethod =
|
||||||
|
teamMember.payout_method === "commission"
|
||||||
|
? t("employee_teams.options.commission")
|
||||||
|
: t("employee_teams.options.hourly");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
|
||||||
|
<Typography.Text strong>{employeeName}</Typography.Text>
|
||||||
|
<Tag bordered={false} color="geekblue">
|
||||||
|
{`${t("employee_teams.fields.allocation")}: ${allocation || "--"}`}
|
||||||
|
</Tag>
|
||||||
|
<Tag bordered={false} color={getPayoutMethodTagColor(teamMember.payout_method)}>
|
||||||
|
{payoutMethod}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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") {
|
if (search.employeeTeamId && search.employeeTeamId !== "new") {
|
||||||
//Update a record.
|
|
||||||
logImEXEvent("shop_employee_update");
|
logImEXEvent("shop_employee_update");
|
||||||
|
|
||||||
const result = await updateEmployeeTeam({
|
const result = await updateEmployeeTeam({
|
||||||
variables: {
|
variables: {
|
||||||
employeeTeamId: search.employeeTeamId,
|
employeeTeamId: search.employeeTeamId,
|
||||||
employeeTeam: values,
|
employeeTeam: values,
|
||||||
teamMemberUpdates: employee_team_members
|
teamMemberUpdates: normalizedTeamMembers
|
||||||
.filter((e) => e.id)
|
.filter((teamMember) => teamMember.id)
|
||||||
.map((e) => {
|
.map((teamMember) => ({
|
||||||
delete e.__typename;
|
where: { id: { _eq: teamMember.id } },
|
||||||
return { where: { id: { _eq: e.id } }, _set: e };
|
_set: teamMember
|
||||||
}),
|
})),
|
||||||
teamMemberInserts: employee_team_members
|
teamMemberInserts: normalizedTeamMembers
|
||||||
.filter((e) => e.id === null || e.id === undefined)
|
.filter((teamMember) => teamMember.id === null || teamMember.id === undefined)
|
||||||
.map((e) => ({ ...e, teamid: search.employeeTeamId })),
|
.map((teamMember) => ({ ...teamMember, teamid: search.employeeTeamId })),
|
||||||
teamMemberDeletes: data.employee_teams_by_pk.employee_team_members.filter(
|
teamMemberDeletes: data.employee_teams_by_pk.employee_team_members
|
||||||
(e) => !employee_team_members.find((etm) => etm.id === e.id)
|
.filter(
|
||||||
)
|
(teamMember) => !normalizedTeamMembers.find((currentTeamMember) => currentTeamMember.id === teamMember.id)
|
||||||
|
)
|
||||||
|
.map((teamMember) => teamMember.id)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.errors) {
|
if (!result.errors) {
|
||||||
notification.success({
|
notification.success({
|
||||||
title: t("employees.successes.save")
|
title: t("employees.successes.save")
|
||||||
@@ -89,20 +200,19 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
//New record, insert it.
|
|
||||||
logImEXEvent("shop_employee_insert");
|
logImEXEvent("shop_employee_insert");
|
||||||
|
|
||||||
insertEmployeeTeam({
|
insertEmployeeTeam({
|
||||||
variables: {
|
variables: {
|
||||||
employeeTeam: {
|
employeeTeam: {
|
||||||
...values,
|
...values,
|
||||||
employee_team_members: { data: employee_team_members },
|
employee_team_members: { data: normalizedTeamMembers },
|
||||||
bodyshopid: bodyshop.id
|
bodyshopid: bodyshop.id
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
refetchQueries: ["QUERY_TEAMS"]
|
refetchQueries: ["QUERY_TEAMS"]
|
||||||
}).then((r) => {
|
}).then((response) => {
|
||||||
search.employeeTeamId = r.data.insert_employee_teams_one.id;
|
search.employeeTeamId = response.data.insert_employee_teams_one.id;
|
||||||
history({ search: querystring.stringify(search) });
|
history({ search: querystring.stringify(search) });
|
||||||
notification.success({
|
notification.success({
|
||||||
title: t("employees.successes.save")
|
title: t("employees.successes.save")
|
||||||
@@ -116,6 +226,7 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
title={teamCardTitle}
|
||||||
extra={
|
extra={
|
||||||
<Button type="primary" onClick={() => form.submit()}>
|
<Button type="primary" onClick={() => form.submit()}>
|
||||||
{t("general.actions.save")}
|
{t("general.actions.save")}
|
||||||
@@ -130,7 +241,6 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
|||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
required: true
|
required: true
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -145,7 +255,6 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
|||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
required: true
|
required: true
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -156,243 +265,149 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
|||||||
{(fields, { add, remove, move }) => {
|
{(fields, { add, remove, move }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => {
|
||||||
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
|
const teamMember = normalizeTeamMember(teamMembers[field.name]);
|
||||||
<Form.Item label={t("employees.fields.id")} key={`${index}`} name={[field.name, "id"]} hidden>
|
|
||||||
<Input type="hidden" />
|
|
||||||
</Form.Item>
|
|
||||||
<LayoutFormRow grow>
|
|
||||||
<Form.Item
|
|
||||||
label={t("employee_teams.fields.employeeid")}
|
|
||||||
key={`${index}`}
|
|
||||||
name={[field.name, "employeeid"]}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<EmployeeSearchSelectComponent options={bodyshop.employees} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label={t("employee_teams.fields.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"]}
|
|
||||||
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 />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
return (
|
||||||
label={t("joblines.fields.lbr_types.LAF")}
|
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
|
||||||
key={`${index}`}
|
<Form.Item label={t("employees.fields.id")} key={`${index}`} name={[field.name, "id"]} hidden>
|
||||||
name={[field.name, "labor_rates", "LAF"]}
|
<Input type="hidden" />
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true
|
|
||||||
//message: t("general.validation.required"),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CurrencyInput />
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<LayoutFormRow
|
||||||
label={t("joblines.fields.lbr_types.LAG")}
|
grow
|
||||||
key={`${index}`}
|
title={getTeamMemberTitle(teamMember)}
|
||||||
name={[field.name, "labor_rates", "LAG"]}
|
extra={
|
||||||
rules={[
|
<Space align="center" size="small">
|
||||||
{
|
<Button
|
||||||
required: true
|
type="text"
|
||||||
//message: t("general.validation.required"),
|
icon={<DeleteFilled />}
|
||||||
}
|
onClick={() => {
|
||||||
]}
|
remove(field.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormListMoveArrows
|
||||||
|
move={move}
|
||||||
|
index={index}
|
||||||
|
total={fields.length}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<CurrencyInput />
|
<div>
|
||||||
</Form.Item>
|
<Row gutter={[16, 0]}>
|
||||||
<Form.Item
|
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.employee}>
|
||||||
label={t("joblines.fields.lbr_types.LAM")}
|
<Form.Item
|
||||||
key={`${index}`}
|
label={t("employee_teams.fields.employeeid")}
|
||||||
name={[field.name, "labor_rates", "LAM"]}
|
key={`${index}`}
|
||||||
rules={[
|
name={[field.name, "employeeid"]}
|
||||||
{
|
rules={[
|
||||||
required: true
|
{
|
||||||
//message: t("general.validation.required"),
|
required: true
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<CurrencyInput />
|
<EmployeeSearchSelectComponent options={bodyshop.employees} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
</Col>
|
||||||
label={t("joblines.fields.lbr_types.LAR")}
|
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.allocation}>
|
||||||
key={`${index}`}
|
<Form.Item
|
||||||
name={[field.name, "labor_rates", "LAR"]}
|
label={t("employee_teams.fields.allocation_percentage")}
|
||||||
rules={[
|
key={`${index}`}
|
||||||
{
|
name={[field.name, "percentage"]}
|
||||||
required: true
|
rules={[
|
||||||
//message: t("general.validation.required"),
|
{
|
||||||
}
|
required: true
|
||||||
]}
|
}
|
||||||
>
|
]}
|
||||||
<CurrencyInput />
|
>
|
||||||
</Form.Item>
|
<InputNumber min={0} max={100} precision={2} />
|
||||||
<Form.Item
|
</Form.Item>
|
||||||
label={t("joblines.fields.lbr_types.LAS")}
|
</Col>
|
||||||
key={`${index}`}
|
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.payoutMethod}>
|
||||||
name={[field.name, "labor_rates", "LAS"]}
|
<Form.Item
|
||||||
rules={[
|
label={t("employee_teams.fields.payout_method")}
|
||||||
{
|
key={`${index}-payout-method`}
|
||||||
required: true
|
name={[field.name, "payout_method"]}
|
||||||
//message: t("general.validation.required"),
|
initialValue="hourly"
|
||||||
}
|
rules={[
|
||||||
]}
|
{
|
||||||
>
|
required: true
|
||||||
<CurrencyInput />
|
}
|
||||||
</Form.Item>
|
]}
|
||||||
<Form.Item
|
>
|
||||||
label={t("joblines.fields.lbr_types.LAU")}
|
<Select options={payoutMethodOptions} />
|
||||||
key={`${index}`}
|
</Form.Item>
|
||||||
name={[field.name, "labor_rates", "LAU"]}
|
</Col>
|
||||||
rules={[
|
</Row>
|
||||||
{
|
<Form.Item noStyle dependencies={[["employee_team_members", field.name, "payout_method"]]}>
|
||||||
required: true
|
{() => {
|
||||||
//message: t("general.validation.required"),
|
const payoutMethod =
|
||||||
}
|
form.getFieldValue(["employee_team_members", field.name, "payout_method"]) || "hourly";
|
||||||
]}
|
const fieldName = payoutMethod === "commission" ? "commission_rates" : "labor_rates";
|
||||||
>
|
|
||||||
<CurrencyInput />
|
return (
|
||||||
</Form.Item>
|
<Row gutter={[16, 0]}>
|
||||||
<Form.Item
|
{LABOR_TYPES.map((laborType) => (
|
||||||
label={t("joblines.fields.lbr_types.LA1")}
|
<Col {...TEAM_MEMBER_RATE_FIELD_COLS} key={`${index}-${fieldName}-${laborType}`}>
|
||||||
key={`${index}`}
|
<Form.Item
|
||||||
name={[field.name, "labor_rates", "LA1"]}
|
label={
|
||||||
rules={[
|
t(`joblines.fields.lbr_types.${laborType}`)
|
||||||
{
|
}
|
||||||
required: true
|
name={[field.name, fieldName, laborType]}
|
||||||
//message: t("general.validation.required"),
|
rules={[
|
||||||
}
|
{
|
||||||
]}
|
required: true
|
||||||
>
|
}
|
||||||
<CurrencyInput />
|
]}
|
||||||
</Form.Item>
|
>
|
||||||
<Form.Item
|
{payoutMethod === "commission" ? (
|
||||||
label={t("joblines.fields.lbr_types.LA2")}
|
<InputNumber min={0} max={100} precision={2} />
|
||||||
key={`${index}`}
|
) : (
|
||||||
name={[field.name, "labor_rates", "LA2"]}
|
<CurrencyInput />
|
||||||
rules={[
|
)}
|
||||||
{
|
</Form.Item>
|
||||||
required: true
|
</Col>
|
||||||
//message: t("general.validation.required"),
|
))}
|
||||||
}
|
</Row>
|
||||||
]}
|
);
|
||||||
>
|
}}
|
||||||
<CurrencyInput />
|
</Form.Item>
|
||||||
</Form.Item>
|
</div>
|
||||||
<Form.Item
|
</LayoutFormRow>
|
||||||
label={t("joblines.fields.lbr_types.LA3")}
|
</Form.Item>
|
||||||
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 />
|
|
||||||
</Form.Item>
|
|
||||||
<Space align="center">
|
|
||||||
<DeleteFilled
|
|
||||||
onClick={() => {
|
|
||||||
remove(field.name);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
|
||||||
</Space>
|
|
||||||
</LayoutFormRow>
|
|
||||||
</Form.Item>
|
|
||||||
))}
|
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button
|
<Button
|
||||||
type="dashed"
|
type="dashed"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
add();
|
add({
|
||||||
|
percentage: 0,
|
||||||
|
payout_method: "hourly",
|
||||||
|
labor_rates: {},
|
||||||
|
commission_rates: {}
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
style={{ width: "100%" }}
|
style={{ width: "100%" }}
|
||||||
>
|
>
|
||||||
{t("employee_teams.actions.newmember")}
|
{t("employee_teams.actions.newmember")}
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -66,10 +66,9 @@ export function TechClockInContainer({ setTimeTicketContext, technician, bodysho
|
|||||||
employeeid: technician.id,
|
employeeid: technician.id,
|
||||||
date:
|
date:
|
||||||
typeof bodyshop.timezone === "string"
|
typeof bodyshop.timezone === "string"
|
||||||
? // TODO: Client Update - This may be broken
|
? dayjs(theTime).tz(bodyshop.timezone).format("YYYY-MM-DD")
|
||||||
dayjs.tz(theTime, bodyshop.timezone).format("YYYY-MM-DD")
|
|
||||||
: typeof bodyshop.timezone === "number"
|
: 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"),
|
: dayjs(theTime).format("YYYY-MM-DD"),
|
||||||
clockon: dayjs(theTime),
|
clockon: dayjs(theTime),
|
||||||
jobid: values.jobid,
|
jobid: values.jobid,
|
||||||
|
|||||||
@@ -25,10 +25,7 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function TechLookupJobsDrawer({ bodyshop, setPrintCenterContext }) {
|
export function TechLookupJobsDrawer({ bodyshop, setPrintCenterContext }) {
|
||||||
const breakpoints = Grid.useBreakpoint();
|
const screens = Grid.useBreakpoint();
|
||||||
const selectedBreakpoint = Object.entries(breakpoints)
|
|
||||||
.filter(([, isOn]) => !!isOn)
|
|
||||||
.slice(-1)[0];
|
|
||||||
|
|
||||||
const bpoints = {
|
const bpoints = {
|
||||||
xs: "100%",
|
xs: "100%",
|
||||||
@@ -36,10 +33,16 @@ export function TechLookupJobsDrawer({ bodyshop, setPrintCenterContext }) {
|
|||||||
md: "100%",
|
md: "100%",
|
||||||
lg: "100%",
|
lg: "100%",
|
||||||
xl: "90%",
|
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 location = useLocation();
|
||||||
const history = useNavigate();
|
const history = useNavigate();
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function TimeTicketModalComponent({
|
|||||||
} = useTreatmentsWithConfig({
|
} = useTreatmentsWithConfig({
|
||||||
attributes: {},
|
attributes: {},
|
||||||
names: ["Enhanced_Payroll"],
|
names: ["Enhanced_Payroll"],
|
||||||
splitKey: bodyshop.imexshopid
|
splitKey: bodyshop?.imexshopid
|
||||||
});
|
});
|
||||||
|
|
||||||
const [loadLineTicketData, { loading, data: lineTicketData, refetch }] = useLazyQuery(GET_LINE_TICKET_BY_PK, {
|
const [loadLineTicketData, { loading, data: lineTicketData, refetch }] = useLazyQuery(GET_LINE_TICKET_BY_PK, {
|
||||||
@@ -347,7 +347,7 @@ export function LaborAllocationContainer({
|
|||||||
} = useTreatmentsWithConfig({
|
} = useTreatmentsWithConfig({
|
||||||
attributes: {},
|
attributes: {},
|
||||||
names: ["Enhanced_Payroll"],
|
names: ["Enhanced_Payroll"],
|
||||||
splitKey: bodyshop.imexshopid
|
splitKey: bodyshop?.imexshopid
|
||||||
});
|
});
|
||||||
|
|
||||||
if (loading) return <LoadingSkeleton />;
|
if (loading) return <LoadingSkeleton />;
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ const mapDispatchToProps = () => ({
|
|||||||
});
|
});
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(TimeTicketTaskModalComponent);
|
export default connect(mapStateToProps, mapDispatchToProps)(TimeTicketTaskModalComponent);
|
||||||
|
|
||||||
|
const getPayoutMethodLabel = (payoutMethod, t) => {
|
||||||
|
if (!payoutMethod) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payoutMethod === "hourly" || payoutMethod === "commission") {
|
||||||
|
return t(`timetickets.labels.payout_methods.${payoutMethod}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payoutMethod;
|
||||||
|
};
|
||||||
|
|
||||||
export function TimeTicketTaskModalComponent({ bodyshop, form, loading, completedTasks, unassignedHours }) {
|
export function TimeTicketTaskModalComponent({ bodyshop, form, loading, completedTasks, unassignedHours }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -35,7 +47,15 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
|
|||||||
<JobSearchSelectComponent convertedOnly={true} notExported={true} />
|
<JobSearchSelectComponent convertedOnly={true} notExported={true} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Form.Item name="task" label={t("timetickets.labels.task")}>
|
<Form.Item
|
||||||
|
name="task"
|
||||||
|
label={t("timetickets.labels.task")}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Spin />
|
<Spin />
|
||||||
) : (
|
) : (
|
||||||
@@ -93,33 +113,51 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
|
|||||||
<th>{t("timetickets.fields.cost_center")}</th>
|
<th>{t("timetickets.fields.cost_center")}</th>
|
||||||
<th>{t("timetickets.fields.ciecacode")}</th>
|
<th>{t("timetickets.fields.ciecacode")}</th>
|
||||||
<th>{t("timetickets.fields.productivehrs")}</th>
|
<th>{t("timetickets.fields.productivehrs")}</th>
|
||||||
|
<th>{t("timetickets.fields.payout_method")}</th>
|
||||||
|
<th>{t("timetickets.fields.rate")}</th>
|
||||||
|
<th>{t("timetickets.fields.amount")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => {
|
||||||
<tr key={field.key}>
|
const payoutMethod = form.getFieldValue(["timetickets", field.name, "payout_context", "payout_method"]);
|
||||||
<td>
|
|
||||||
<Form.Item key={`${index}employeeid`} name={[field.name, "employeeid"]}>
|
return (
|
||||||
<ReadOnlyFormItemComponent type="employee" />
|
<tr key={field.key}>
|
||||||
</Form.Item>
|
<td>
|
||||||
</td>
|
<Form.Item key={`${index}employeeid`} name={[field.name, "employeeid"]}>
|
||||||
<td>
|
<ReadOnlyFormItemComponent type="employee" />
|
||||||
<Form.Item key={`${index}cost_center`} name={[field.name, "cost_center"]}>
|
</Form.Item>
|
||||||
<ReadOnlyFormItemComponent />
|
</td>
|
||||||
</Form.Item>
|
<td>
|
||||||
</td>
|
<Form.Item key={`${index}cost_center`} name={[field.name, "cost_center"]}>
|
||||||
<td>
|
<ReadOnlyFormItemComponent />
|
||||||
<Form.Item key={`${index}ciecacode`} name={[field.name, "ciecacode"]}>
|
</Form.Item>
|
||||||
<ReadOnlyFormItemComponent />
|
</td>
|
||||||
</Form.Item>
|
<td>
|
||||||
</td>
|
<Form.Item key={`${index}ciecacode`} name={[field.name, "ciecacode"]}>
|
||||||
<td>
|
<ReadOnlyFormItemComponent />
|
||||||
<Form.Item key={`${index}productivehrs`} name={[field.name, "productivehrs"]}>
|
</Form.Item>
|
||||||
<ReadOnlyFormItemComponent />
|
</td>
|
||||||
</Form.Item>
|
<td>
|
||||||
</td>
|
<Form.Item key={`${index}productivehrs`} name={[field.name, "productivehrs"]}>
|
||||||
</tr>
|
<ReadOnlyFormItemComponent />
|
||||||
))}
|
</Form.Item>
|
||||||
|
</td>
|
||||||
|
<td>{getPayoutMethodLabel(payoutMethod, t)}</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<Alert type="success" title={t("timetickets.labels.payrollclaimedtasks")} />
|
<Alert type="success" title={t("timetickets.labels.payrollclaimedtasks")} />
|
||||||
|
|||||||
@@ -25,6 +25,22 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
});
|
});
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(TimeTickeTaskModalContainer);
|
export default connect(mapStateToProps, mapDispatchToProps)(TimeTickeTaskModalContainer);
|
||||||
|
|
||||||
|
const toFiniteNumber = (value) => {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPreviewPayoutAmount = (ticket) => {
|
||||||
|
const productiveHours = toFiniteNumber(ticket?.productivehrs);
|
||||||
|
const rate = toFiniteNumber(ticket?.rate);
|
||||||
|
|
||||||
|
if (productiveHours === null || rate === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return productiveHours * rate;
|
||||||
|
};
|
||||||
|
|
||||||
export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicketTasksModal, toggleModalVisible }) {
|
export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicketTasksModal, toggleModalVisible }) {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const { context, open, actions } = timeTicketTasksModal;
|
const { context, open, actions } = timeTicketTasksModal;
|
||||||
@@ -90,7 +106,12 @@ export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicke
|
|||||||
if (actions?.refetch) actions.refetch();
|
if (actions?.refetch) actions.refetch();
|
||||||
toggleModalVisible();
|
toggleModalVisible();
|
||||||
} else if (handleFinish === false) {
|
} else if (handleFinish === false) {
|
||||||
form.setFieldsValue({ timetickets: data.ticketsToInsert });
|
form.setFieldsValue({
|
||||||
|
timetickets: (data.ticketsToInsert || []).map((ticket) => ({
|
||||||
|
...ticket,
|
||||||
|
payoutamount: getPreviewPayoutAmount(ticket)
|
||||||
|
}))
|
||||||
|
});
|
||||||
setUnassignedHours(data.unassignedHours);
|
setUnassignedHours(data.unassignedHours);
|
||||||
} else {
|
} else {
|
||||||
notification.error({
|
notification.error({
|
||||||
@@ -101,7 +122,9 @@ export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicke
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notification.error({
|
notification.error({
|
||||||
title: t("timetickets.errors.creating", { message: error.message })
|
title: t("timetickets.errors.creating", {
|
||||||
|
message: error.response?.data?.error || error.message
|
||||||
|
})
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -130,7 +130,15 @@ export function TtApprovalsListComponent({
|
|||||||
key: "memo",
|
key: "memo",
|
||||||
sorter: (a, b) => alphaSort(a.memo, b.memo),
|
sorter: (a, b) => alphaSort(a.memo, b.memo),
|
||||||
sortOrder: state.sortedInfo.columnKey === "memo" && state.sortedInfo.order,
|
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"),
|
title: t("timetickets.fields.clockon"),
|
||||||
@@ -140,12 +148,12 @@ export function TtApprovalsListComponent({
|
|||||||
render: (text, record) => <DateTimeFormatter>{record.clockon}</DateTimeFormatter>
|
render: (text, record) => <DateTimeFormatter>{record.clockon}</DateTimeFormatter>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Pay",
|
title: t("timetickets.fields.pay"),
|
||||||
dataIndex: "pay",
|
dataIndex: "pay",
|
||||||
key: "pay",
|
key: "pay",
|
||||||
render: (text, record) =>
|
render: (text, record) =>
|
||||||
Dinero({ amount: Math.round(record.rate * 100) })
|
Dinero({ amount: Math.round((record.rate || 0) * 100) })
|
||||||
.multiply(record.flat_rate ? record.productivehrs : record.actualhrs)
|
.multiply(record.flat_rate ? record.productivehrs || 0 : record.actualhrs || 0)
|
||||||
.toFormat("$0.00")
|
.toFormat("$0.00")
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -184,7 +192,7 @@ export function TtApprovalsListComponent({
|
|||||||
<ResponsiveTable
|
<ResponsiveTable
|
||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
mobileColumnKeys={["ro_number", "date", "employeeid", "cost_center"]}
|
mobileColumnKeys={["ro_number", "date", "employeeid", "cost_center", "task_name"]}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
scroll={{
|
scroll={{
|
||||||
x: true
|
x: true
|
||||||
|
|||||||
@@ -18,7 +18,15 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
authLevel: selectAuthLevel
|
authLevel: selectAuthLevel
|
||||||
});
|
});
|
||||||
|
|
||||||
export function TtApproveButton({ bodyshop, currentUser, selectedTickets, disabled, authLevel }) {
|
export function TtApproveButton({
|
||||||
|
bodyshop,
|
||||||
|
currentUser,
|
||||||
|
selectedTickets,
|
||||||
|
disabled,
|
||||||
|
authLevel,
|
||||||
|
completedCallback,
|
||||||
|
refetch
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const client = useApolloClient();
|
const client = useApolloClient();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
@@ -54,6 +62,12 @@ export function TtApproveButton({ bodyshop, currentUser, selectedTickets, disabl
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
if (typeof completedCallback === "function") {
|
||||||
|
completedCallback([]);
|
||||||
|
}
|
||||||
|
if (typeof refetch === "function") {
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
notification.success({
|
notification.success({
|
||||||
title: t("timetickets.successes.created")
|
title: t("timetickets.successes.created")
|
||||||
});
|
});
|
||||||
@@ -68,8 +82,6 @@ export function TtApproveButton({ bodyshop, currentUser, selectedTickets, disabl
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (!!completedCallback) completedCallback([]);
|
|
||||||
// if (!!loadingCallback) loadingCallback(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ export const QUERY_PARTS_BILLS_BY_JOBID = gql`
|
|||||||
order_number
|
order_number
|
||||||
comments
|
comments
|
||||||
user_email
|
user_email
|
||||||
|
bill {
|
||||||
|
id
|
||||||
|
invoice_number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
parts_dispatch(where: { jobid: { _eq: $jobid } }) {
|
parts_dispatch(where: { jobid: { _eq: $jobid } }) {
|
||||||
id
|
id
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ export const QUERY_BODYSHOP = gql`
|
|||||||
id
|
id
|
||||||
employeeid
|
employeeid
|
||||||
labor_rates
|
labor_rates
|
||||||
|
payout_method
|
||||||
|
commission_rates
|
||||||
percentage
|
percentage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,6 +287,8 @@ export const UPDATE_SHOP = gql`
|
|||||||
id
|
id
|
||||||
employeeid
|
employeeid
|
||||||
labor_rates
|
labor_rates
|
||||||
|
payout_method
|
||||||
|
commission_rates
|
||||||
percentage
|
percentage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export const QUERY_TEAMS = gql`
|
|||||||
id
|
id
|
||||||
employeeid
|
employeeid
|
||||||
labor_rates
|
labor_rates
|
||||||
|
payout_method
|
||||||
|
commission_rates
|
||||||
percentage
|
percentage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,6 +31,8 @@ export const UPDATE_EMPLOYEE_TEAM = gql`
|
|||||||
employeeid
|
employeeid
|
||||||
id
|
id
|
||||||
labor_rates
|
labor_rates
|
||||||
|
payout_method
|
||||||
|
commission_rates
|
||||||
percentage
|
percentage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,6 +44,8 @@ export const UPDATE_EMPLOYEE_TEAM = gql`
|
|||||||
employeeid
|
employeeid
|
||||||
id
|
id
|
||||||
labor_rates
|
labor_rates
|
||||||
|
payout_method
|
||||||
|
commission_rates
|
||||||
percentage
|
percentage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,6 +58,8 @@ export const UPDATE_EMPLOYEE_TEAM = gql`
|
|||||||
employeeid
|
employeeid
|
||||||
id
|
id
|
||||||
labor_rates
|
labor_rates
|
||||||
|
payout_method
|
||||||
|
commission_rates
|
||||||
percentage
|
percentage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,6 +77,8 @@ export const INSERT_EMPLOYEE_TEAM = gql`
|
|||||||
employeeid
|
employeeid
|
||||||
id
|
id
|
||||||
labor_rates
|
labor_rates
|
||||||
|
payout_method
|
||||||
|
commission_rates
|
||||||
percentage
|
percentage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,6 +96,8 @@ export const QUERY_EMPLOYEE_TEAM_BY_ID = gql`
|
|||||||
employeeid
|
employeeid
|
||||||
id
|
id
|
||||||
labor_rates
|
labor_rates
|
||||||
|
payout_method
|
||||||
|
commission_rates
|
||||||
percentage
|
percentage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1375,6 +1375,9 @@ export const QUERY_JOB_FOR_DUPE = gql`
|
|||||||
agt_ph2x
|
agt_ph2x
|
||||||
area_of_damage
|
area_of_damage
|
||||||
cat_no
|
cat_no
|
||||||
|
cieca_pfl
|
||||||
|
cieca_pfo
|
||||||
|
cieca_pft
|
||||||
cieca_stl
|
cieca_stl
|
||||||
cieca_ttl
|
cieca_ttl
|
||||||
clm_addr1
|
clm_addr1
|
||||||
@@ -1452,6 +1455,7 @@ export const QUERY_JOB_FOR_DUPE = gql`
|
|||||||
labor_rate_desc
|
labor_rate_desc
|
||||||
labor_rate_id
|
labor_rate_id
|
||||||
local_tax_rate
|
local_tax_rate
|
||||||
|
materials
|
||||||
other_amount_payable
|
other_amount_payable
|
||||||
owner_owing
|
owner_owing
|
||||||
ownerid
|
ownerid
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ export const INSERT_TIME_TICKET_AND_APPROVE = gql`
|
|||||||
id
|
id
|
||||||
clockon
|
clockon
|
||||||
clockoff
|
clockoff
|
||||||
|
created_by
|
||||||
employeeid
|
employeeid
|
||||||
productivehrs
|
productivehrs
|
||||||
actualhrs
|
actualhrs
|
||||||
@@ -267,6 +268,9 @@ export const INSERT_TIME_TICKET_AND_APPROVE = gql`
|
|||||||
date
|
date
|
||||||
memo
|
memo
|
||||||
flat_rate
|
flat_rate
|
||||||
|
task_name
|
||||||
|
payout_context
|
||||||
|
ttapprovalqueueid
|
||||||
commited_by
|
commited_by
|
||||||
committed_at
|
committed_at
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,14 @@ export const QUERY_ALL_TT_APPROVALS_PAGINATED = gql`
|
|||||||
ciecacode
|
ciecacode
|
||||||
cost_center
|
cost_center
|
||||||
date
|
date
|
||||||
|
memo
|
||||||
|
flat_rate
|
||||||
|
clockon
|
||||||
|
clockoff
|
||||||
rate
|
rate
|
||||||
|
created_by
|
||||||
|
task_name
|
||||||
|
payout_context
|
||||||
}
|
}
|
||||||
tt_approval_queue_aggregate {
|
tt_approval_queue_aggregate {
|
||||||
aggregate {
|
aggregate {
|
||||||
@@ -42,9 +49,16 @@ export const INSERT_NEW_TT_APPROVALS = gql`
|
|||||||
productivehrs
|
productivehrs
|
||||||
actualhrs
|
actualhrs
|
||||||
ciecacode
|
ciecacode
|
||||||
|
cost_center
|
||||||
date
|
date
|
||||||
memo
|
memo
|
||||||
flat_rate
|
flat_rate
|
||||||
|
rate
|
||||||
|
clockon
|
||||||
|
clockoff
|
||||||
|
created_by
|
||||||
|
task_name
|
||||||
|
payout_context
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,6 +79,11 @@ export const QUERY_TT_APPROVALS_BY_IDS = gql`
|
|||||||
ciecacode
|
ciecacode
|
||||||
bodyshopid
|
bodyshopid
|
||||||
cost_center
|
cost_center
|
||||||
|
clockon
|
||||||
|
clockoff
|
||||||
|
created_by
|
||||||
|
task_name
|
||||||
|
payout_context
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
|||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
|
||||||
import { QUERY_JOB_EXPORT_DMS } from "../../graphql/jobs.queries";
|
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 { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
|
||||||
|
|
||||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
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";
|
import RrAllocationsSummary from "../../components/dms-allocations-summary/rr-dms-allocations-summary.component";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop,
|
||||||
|
currentUser: selectCurrentUser
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
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 {
|
const {
|
||||||
treatments: { Fortellis }
|
treatments: { Fortellis }
|
||||||
} = useTreatmentsWithConfig({
|
} = useTreatmentsWithConfig({
|
||||||
@@ -79,6 +114,15 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
|||||||
const [allocationsSummary, setAllocationsSummary] = useState(null);
|
const [allocationsSummary, setAllocationsSummary] = useState(null);
|
||||||
const [reconnectNonce, setReconnectNonce] = useState(0);
|
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
|
// Compute a single normalized mode and pick the proper socket
|
||||||
const mode = getDmsMode(bodyshop, Fortellis.treatment); // "rr" | "fortellis" | "cdk" | "pbs" | "none"
|
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(
|
const providerLabel = useMemo(
|
||||||
() =>
|
() =>
|
||||||
({
|
({
|
||||||
[DMS_MAP.reynolds]: "Reynolds",
|
[DMS_MAP.reynolds]: t("dms.labels.provider_reynolds"),
|
||||||
[DMS_MAP.fortellis]: "Fortellis",
|
[DMS_MAP.fortellis]: t("dms.labels.provider_fortellis"),
|
||||||
[DMS_MAP.cdk]: "CDK",
|
[DMS_MAP.cdk]: t("dms.labels.provider_cdk"),
|
||||||
[DMS_MAP.pbs]: "PBS"
|
[DMS_MAP.pbs]: t("dms.labels.provider_pbs")
|
||||||
})[mode] || "DMS",
|
})[mode] || t("dms.labels.provider_dms"),
|
||||||
[mode]
|
[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} | ${
|
const bannerMessage = t("dms.labels.banner_message", {
|
||||||
isConnected ? "Connected" : "Disconnected"
|
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 resetKey = useMemo(() => `${mode || "none"}-${jobId || "none"}`, [mode, jobId]);
|
||||||
const customerSelectorKey = useMemo(() => `${resetKey}-${reconnectNonce}`, [resetKey, reconnectNonce]);
|
const customerSelectorKey = useMemo(() => `${resetKey}-${reconnectNonce}`, [resetKey, reconnectNonce]);
|
||||||
@@ -239,6 +285,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
|||||||
}, [jobId, mode, activeSocket]);
|
}, [jobId, mode, activeSocket]);
|
||||||
|
|
||||||
const handleExportFailed = (payload = {}) => {
|
const handleExportFailed = (payload = {}) => {
|
||||||
|
const safePayload = canViewSensitiveRrXml ? payload : stripRrXmlFromPayload(payload);
|
||||||
const { title, friendlyMessage, error: errText, severity, errorCode, vendorStatusCode } = payload;
|
const { title, friendlyMessage, error: errText, severity, errorCode, vendorStatusCode } = payload;
|
||||||
|
|
||||||
const msg =
|
const msg =
|
||||||
@@ -246,7 +293,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
|||||||
errText ||
|
errText ||
|
||||||
t("dms.errors.exportfailedgeneric", "We couldn't complete the export. Please try again.");
|
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 =
|
const isRrOpenRoLimit =
|
||||||
isRrMode &&
|
isRrMode &&
|
||||||
@@ -269,7 +316,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
|||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
level: (sev || "error").toUpperCase(),
|
level: (sev || "error").toUpperCase(),
|
||||||
message: `${vendorTitle}: ${msg}`,
|
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(),
|
timestamp: new Date(),
|
||||||
level: "warn",
|
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
|
// Logs
|
||||||
const onLog = isRrMode
|
const onLog = isRrMode
|
||||||
? (payload = {}) => {
|
? (payload = {}) => {
|
||||||
|
const safePayload = canViewSensitiveRrXml ? payload : stripRrXmlFromPayload(payload);
|
||||||
const normalized = {
|
const normalized = {
|
||||||
timestamp: payload.timestamp ? new Date(payload.timestamp) : payload.ts ? new Date(payload.ts) : new Date(),
|
timestamp: safePayload.timestamp
|
||||||
level: (payload.level || "INFO").toUpperCase(),
|
? new Date(safePayload.timestamp)
|
||||||
message: payload.message || payload.msg || "",
|
: safePayload.ts
|
||||||
meta: payload.meta ?? payload.ctx ?? payload.details ?? null
|
? 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]);
|
setLogs((prev) => [...prev, normalized]);
|
||||||
}
|
}
|
||||||
@@ -380,14 +434,12 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
|||||||
{
|
{
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
level: "INFO",
|
level: "INFO",
|
||||||
message:
|
message: t("dms.labels.rr_validation_message")
|
||||||
"Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize."
|
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
notification.info({
|
notification.info({
|
||||||
title: "Reynolds RO created",
|
title: t("dms.labels.rr_validation_notice_title"),
|
||||||
description:
|
description: t("dms.labels.rr_validation_notice_description"),
|
||||||
"Complete validation in Reynolds, then click Finished/Close to finalize and mark this export complete.",
|
|
||||||
duration: 8
|
duration: 8
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -399,8 +451,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
|||||||
{
|
{
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
level: "INFO",
|
level: "INFO",
|
||||||
message:
|
message: t("dms.labels.rr_validation_message"),
|
||||||
"Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize.",
|
|
||||||
meta: { payload }
|
meta: { payload }
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
@@ -428,7 +479,19 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
|||||||
activeSocket.disconnect();
|
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)
|
// RR finalize callback (unchanged public behavior)
|
||||||
const handleRrValidationFinished = () => {
|
const handleRrValidationFinished = () => {
|
||||||
@@ -471,7 +534,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
|||||||
<AlertComponent style={{ marginBottom: 10 }} title={bannerMessage} type="warning" showIcon closable />
|
<AlertComponent style={{ marginBottom: 10 }} title={bannerMessage} type="warning" showIcon closable />
|
||||||
|
|
||||||
<Row gutter={[16, 16]}>
|
<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 ? (
|
{!isRrMode ? (
|
||||||
<DmsAllocationsSummary
|
<DmsAllocationsSummary
|
||||||
key={resetKey}
|
key={resetKey}
|
||||||
@@ -511,7 +574,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
|||||||
)}
|
)}
|
||||||
</Col>
|
</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
|
<DmsPostForm
|
||||||
key={resetKey}
|
key={resetKey}
|
||||||
socket={activeSocket}
|
socket={activeSocket}
|
||||||
@@ -550,15 +613,17 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
|||||||
<Switch
|
<Switch
|
||||||
checked={colorizeJson}
|
checked={colorizeJson}
|
||||||
onChange={setColorizeJson}
|
onChange={setColorizeJson}
|
||||||
checkedChildren="Color JSON"
|
checkedChildren={t("dms.labels.color_json")}
|
||||||
unCheckedChildren="Plain 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
|
<Select
|
||||||
placeholder="Log Level"
|
placeholder={t("dms.labels.log_level")}
|
||||||
value={logLevel}
|
value={logLevel}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setLogLevel(value);
|
setLogLevel(value);
|
||||||
@@ -572,8 +637,8 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
|||||||
{ key: "ERROR", value: "ERROR", label: "ERROR" }
|
{ key: "ERROR", value: "ERROR", label: "ERROR" }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => setLogs([])}>Clear Logs</Button>
|
<Button onClick={() => setLogs([])}>{t("dms.labels.clear_logs")}</Button>
|
||||||
<Button onClick={handleReconnectClick}>Reconnect</Button>
|
<Button onClick={handleReconnectClick}> {t("dms.labels.reconnect")}</Button>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -585,6 +650,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
|||||||
detailsNonce={detailsNonce}
|
detailsNonce={detailsNonce}
|
||||||
colorizeJson={isRrMode ? colorizeJson : false}
|
colorizeJson={isRrMode ? colorizeJson : false}
|
||||||
showDetails={isRrMode}
|
showDetails={isRrMode}
|
||||||
|
allowXmlPayload={canViewSensitiveRrXml}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -142,13 +142,13 @@ export function ExportLogsPageComponent() {
|
|||||||
<div>
|
<div>
|
||||||
<ul>
|
<ul>
|
||||||
{message.map((m, idx) => (
|
{message.map((m, idx) => (
|
||||||
<li key={idx}>{m}</li>
|
<li key={idx}>{typeof m === "object" ? JSON.stringify(m) : m}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return <div>{record.message}</div>;
|
return <div>{typeof message === "object" ? JSON.stringify(message) : message}</div>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 JobsCreateVehicleInfoContainer from "../../components/jobs-create-vehicle-info/jobs-create-vehicle-info.container";
|
||||||
import JobCreateContext from "../../pages/jobs-create/jobs-create.context";
|
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 [pageIndex, setPageIndex] = useState(0);
|
||||||
|
|
||||||
const [errorMessage, setErrorMessage] = useState(null);
|
const [errorMessage, setErrorMessage] = useState(null);
|
||||||
|
|
||||||
const [state] = useContext(JobCreateContext);
|
const [state] = useContext(JobCreateContext);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
title: t("jobs.labels.create.vehicleinfo"),
|
title: t("jobs.labels.create.vehicleinfo"),
|
||||||
@@ -42,11 +40,9 @@ export default function JobsCreateComponent({ form }) {
|
|||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
setPageIndex(pageIndex + 1);
|
setPageIndex(pageIndex + 1);
|
||||||
console.log("Next");
|
|
||||||
};
|
};
|
||||||
const prev = () => {
|
const prev = () => {
|
||||||
setPageIndex(pageIndex - 1);
|
setPageIndex(pageIndex - 1);
|
||||||
console.log("Previous");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProgressButtons = ({ top }) => {
|
const ProgressButtons = ({ top }) => {
|
||||||
@@ -79,17 +75,19 @@ export default function JobsCreateComponent({ form }) {
|
|||||||
{pageIndex === steps.length - 1 && (
|
{pageIndex === steps.length - 1 && (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
|
loading={isSubmitting}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
form
|
form
|
||||||
.validateFields()
|
.validateFields()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// NO OP
|
form.submit();
|
||||||
})
|
})
|
||||||
.catch((error) => console.log("error", error));
|
.catch((error) => {
|
||||||
form.submit();
|
console.log("error", error);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Done
|
{t("general.actions.done")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -146,13 +144,11 @@ export default function JobsCreateComponent({ form }) {
|
|||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<ProgressButtons top />
|
<ProgressButtons top />
|
||||||
|
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<div>
|
<div>
|
||||||
<AlertComponent title={errorMessage} type="error" />
|
<AlertComponent title={errorMessage} type="error" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{steps.map((item, idx) => (
|
{steps.map((item, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr
|
|||||||
});
|
});
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [state, setState] = contextState;
|
const [state, setState] = contextState;
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [insertJob] = useMutation(INSERT_NEW_JOB);
|
const [insertJob] = useMutation(INSERT_NEW_JOB);
|
||||||
const [loadOwner, remoteOwnerData] = useLazyQuery(QUERY_OWNER_FOR_JOB_CREATION);
|
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
|
newJobId: resp.data.insert_jobs.returning[0].id
|
||||||
});
|
});
|
||||||
logImEXEvent("manual_job_create_completed", {});
|
logImEXEvent("manual_job_create_completed", {});
|
||||||
|
setIsSubmitting(false);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
notification.error({
|
notification.error({
|
||||||
title: t("jobs.errors.creating", { error: error })
|
title: t("jobs.errors.creating", { error: error })
|
||||||
});
|
});
|
||||||
setState({ ...state, error: error });
|
setState({ ...state, error: error });
|
||||||
|
setIsSubmitting(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFinish = (values) => {
|
const handleFinish = (values) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
let job = Object.assign(
|
let job = Object.assign(
|
||||||
{},
|
{},
|
||||||
values,
|
values,
|
||||||
@@ -297,7 +301,7 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<JobsCreateComponent form={form} />
|
<JobsCreateComponent form={form} isSubmitting={isSubmitting} />
|
||||||
</Form>
|
</Form>
|
||||||
</RbacWrapper>
|
</RbacWrapper>
|
||||||
</JobCreateContext.Provider>
|
</JobCreateContext.Provider>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { combineReducers } from "redux";
|
import { combineReducers } from "redux";
|
||||||
import { persistReducer } from "redux-persist";
|
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 { withReduxStateSync } from "redux-state-sync";
|
||||||
import applicationReducer from "./application/application.reducer";
|
import applicationReducer from "./application/application.reducer";
|
||||||
import emailReducer from "./email/email.reducer";
|
import emailReducer from "./email/email.reducer";
|
||||||
@@ -11,6 +11,8 @@ import techReducer from "./tech/tech.reducer";
|
|||||||
import userReducer from "./user/user.reducer";
|
import userReducer from "./user/user.reducer";
|
||||||
import trelloReducer from "./trello/trello.reducer";
|
import trelloReducer from "./trello/trello.reducer";
|
||||||
|
|
||||||
|
const storage = storageModule?.default ?? storageModule;
|
||||||
|
|
||||||
// const persistConfig = {
|
// const persistConfig = {
|
||||||
// key: "root",
|
// key: "root",
|
||||||
// storage,
|
// storage,
|
||||||
|
|||||||
@@ -305,7 +305,8 @@
|
|||||||
"creatingdefaultview": "Error creating default view.",
|
"creatingdefaultview": "Error creating default view.",
|
||||||
"duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique",
|
"duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique",
|
||||||
"loading": "Unable to load shop details. Please call technical support.",
|
"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": {
|
"fields": {
|
||||||
"ReceivableCustomField": "QBO Receivable Custom Field {{number}}",
|
"ReceivableCustomField": "QBO Receivable Custom Field {{number}}",
|
||||||
@@ -519,6 +520,7 @@
|
|||||||
"list-active": "Jobs -> List Active",
|
"list-active": "Jobs -> List Active",
|
||||||
"list-all": "Jobs -> List All",
|
"list-all": "Jobs -> List All",
|
||||||
"list-ready": "Jobs -> List Ready",
|
"list-ready": "Jobs -> List Ready",
|
||||||
|
"manual-line": "Jobs -> Manual Line",
|
||||||
"partsqueue": "Jobs -> Parts Queue",
|
"partsqueue": "Jobs -> Parts Queue",
|
||||||
"void": "Jobs -> Void"
|
"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."
|
"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": {
|
"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": {
|
"documents": {
|
||||||
@@ -1145,12 +1176,28 @@
|
|||||||
"new": "New Team",
|
"new": "New Team",
|
||||||
"newmember": "New Team Member"
|
"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": {
|
"fields": {
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
|
"allocation": "Allocation",
|
||||||
|
"allocation_percentage": "Allocation %",
|
||||||
"employeeid": "Employee",
|
"employeeid": "Employee",
|
||||||
"max_load": "Max Load",
|
"max_load": "Max Load",
|
||||||
"name": "Team Name",
|
"name": "Team Name",
|
||||||
|
"payout_method": "Payout Method",
|
||||||
"percentage": "Percent"
|
"percentage": "Percent"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"allocation_total": "Allocation Total: {{total}}%"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"commission": "Commission",
|
||||||
|
"commission_percentage": "Commission %",
|
||||||
|
"hourly": "Hourly"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"employees": {
|
"employees": {
|
||||||
@@ -1266,6 +1313,7 @@
|
|||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleteall": "Delete All",
|
"deleteall": "Delete All",
|
||||||
"deselectall": "Deselect All",
|
"deselectall": "Deselect All",
|
||||||
|
"done": "Done",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"gotoadmin": "Go to Admin Panel",
|
"gotoadmin": "Go to Admin Panel",
|
||||||
@@ -3343,8 +3391,10 @@
|
|||||||
"void_ros": "Void ROs",
|
"void_ros": "Void ROs",
|
||||||
"work_in_progress_committed_labour": "Work in Progress - Committed Labor",
|
"work_in_progress_committed_labour": "Work in Progress - Committed Labor",
|
||||||
"work_in_progress_jobs": "Work in Progress - Jobs",
|
"work_in_progress_jobs": "Work in Progress - Jobs",
|
||||||
"work_in_progress_labour": "Work in Progress - Labor",
|
"work_in_progress_labour": "Work in Progress - Labor (Detail)",
|
||||||
"work_in_progress_payables": "Work in Progress - Payables"
|
"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": {
|
"schedule": {
|
||||||
@@ -3561,6 +3611,7 @@
|
|||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"actualhrs": "Actual Hours",
|
"actualhrs": "Actual Hours",
|
||||||
|
"amount": "Amount",
|
||||||
"ciecacode": "CIECA Code",
|
"ciecacode": "CIECA Code",
|
||||||
"clockhours": "Clock Hours",
|
"clockhours": "Clock Hours",
|
||||||
"clockoff": "Clock Off",
|
"clockoff": "Clock Off",
|
||||||
@@ -3575,7 +3626,10 @@
|
|||||||
"employee_team": "Employee Team",
|
"employee_team": "Employee Team",
|
||||||
"flat_rate": "Flat Rate?",
|
"flat_rate": "Flat Rate?",
|
||||||
"memo": "Memo",
|
"memo": "Memo",
|
||||||
|
"pay": "Pay",
|
||||||
|
"payout_method": "Payout Method",
|
||||||
"productivehrs": "Productive Hours",
|
"productivehrs": "Productive Hours",
|
||||||
|
"rate": "Rate",
|
||||||
"ro_number": "Job to Post Against",
|
"ro_number": "Job to Post Against",
|
||||||
"task_name": "Task"
|
"task_name": "Task"
|
||||||
},
|
},
|
||||||
@@ -3594,6 +3648,10 @@
|
|||||||
"lunch": "Lunch",
|
"lunch": "Lunch",
|
||||||
"new": "New Time Ticket",
|
"new": "New Time Ticket",
|
||||||
"payrollclaimedtasks": "These time tickets will be automatically entered to the system as a part of claiming this task. These numbers are calculated using the jobs assigned lines. If lines are unassigned, they will be excluded from created tickets.",
|
"payrollclaimedtasks": "These time tickets will be automatically entered to the system as a part of claiming this task. These numbers are calculated using the jobs assigned lines. If lines are unassigned, they will be excluded from created tickets.",
|
||||||
|
"payout_methods": {
|
||||||
|
"commission": "Commission",
|
||||||
|
"hourly": "Hourly"
|
||||||
|
},
|
||||||
"pmbreak": "PM Break",
|
"pmbreak": "PM Break",
|
||||||
"pmshift": "PM Shift",
|
"pmshift": "PM Shift",
|
||||||
"shift": "Shift",
|
"shift": "Shift",
|
||||||
|
|||||||
@@ -305,7 +305,8 @@
|
|||||||
"creatingdefaultview": "",
|
"creatingdefaultview": "",
|
||||||
"duplicate_insurance_company": "",
|
"duplicate_insurance_company": "",
|
||||||
"loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.",
|
"loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.",
|
||||||
"saving": ""
|
"saving": "",
|
||||||
|
"task_preset_allocation_exceeded": ""
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"ReceivableCustomField": "",
|
"ReceivableCustomField": "",
|
||||||
@@ -519,6 +520,7 @@
|
|||||||
"list-active": "",
|
"list-active": "",
|
||||||
"list-all": "",
|
"list-all": "",
|
||||||
"list-ready": "",
|
"list-ready": "",
|
||||||
|
"manual-line": "",
|
||||||
"partsqueue": "",
|
"partsqueue": "",
|
||||||
"void": ""
|
"void": ""
|
||||||
},
|
},
|
||||||
@@ -1074,7 +1076,36 @@
|
|||||||
"earlyrorequired.message": ""
|
"earlyrorequired.message": ""
|
||||||
},
|
},
|
||||||
"labels": {
|
"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": {
|
"documents": {
|
||||||
@@ -1145,12 +1176,28 @@
|
|||||||
"new": "",
|
"new": "",
|
||||||
"newmember": ""
|
"newmember": ""
|
||||||
},
|
},
|
||||||
|
"errors": {
|
||||||
|
"allocation_total_exact": "",
|
||||||
|
"duplicate_member": "",
|
||||||
|
"minimum_one_member": ""
|
||||||
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"active": "",
|
"active": "",
|
||||||
|
"allocation": "",
|
||||||
|
"allocation_percentage": "",
|
||||||
"employeeid": "",
|
"employeeid": "",
|
||||||
"max_load": "",
|
"max_load": "",
|
||||||
"name": "",
|
"name": "",
|
||||||
|
"payout_method": "",
|
||||||
"percentage": ""
|
"percentage": ""
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"allocation_total": ""
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"commission": "",
|
||||||
|
"commission_percentage": "",
|
||||||
|
"hourly": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"employees": {
|
"employees": {
|
||||||
@@ -1266,6 +1313,7 @@
|
|||||||
"delete": "Borrar",
|
"delete": "Borrar",
|
||||||
"deleteall": "",
|
"deleteall": "",
|
||||||
"deselectall": "",
|
"deselectall": "",
|
||||||
|
"done": "",
|
||||||
"download": "",
|
"download": "",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"gotoadmin": "",
|
"gotoadmin": "",
|
||||||
@@ -3344,7 +3392,9 @@
|
|||||||
"work_in_progress_committed_labour": "",
|
"work_in_progress_committed_labour": "",
|
||||||
"work_in_progress_jobs": "",
|
"work_in_progress_jobs": "",
|
||||||
"work_in_progress_labour": "",
|
"work_in_progress_labour": "",
|
||||||
"work_in_progress_payables": ""
|
"work_in_progress_labour_summary": "",
|
||||||
|
"work_in_progress_payables": "",
|
||||||
|
"work_in_progress_payables_summary": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -3561,6 +3611,7 @@
|
|||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"actualhrs": "",
|
"actualhrs": "",
|
||||||
|
"amount": "",
|
||||||
"ciecacode": "",
|
"ciecacode": "",
|
||||||
"clockhours": "",
|
"clockhours": "",
|
||||||
"clockoff": "",
|
"clockoff": "",
|
||||||
@@ -3575,7 +3626,10 @@
|
|||||||
"employee_team": "",
|
"employee_team": "",
|
||||||
"flat_rate": "",
|
"flat_rate": "",
|
||||||
"memo": "",
|
"memo": "",
|
||||||
|
"pay": "",
|
||||||
|
"payout_method": "",
|
||||||
"productivehrs": "",
|
"productivehrs": "",
|
||||||
|
"rate": "",
|
||||||
"ro_number": "",
|
"ro_number": "",
|
||||||
"task_name": ""
|
"task_name": ""
|
||||||
},
|
},
|
||||||
@@ -3594,6 +3648,10 @@
|
|||||||
"lunch": "",
|
"lunch": "",
|
||||||
"new": "",
|
"new": "",
|
||||||
"payrollclaimedtasks": "",
|
"payrollclaimedtasks": "",
|
||||||
|
"payout_methods": {
|
||||||
|
"commission": "",
|
||||||
|
"hourly": ""
|
||||||
|
},
|
||||||
"pmbreak": "",
|
"pmbreak": "",
|
||||||
"pmshift": "",
|
"pmshift": "",
|
||||||
"shift": "",
|
"shift": "",
|
||||||
|
|||||||
@@ -305,7 +305,8 @@
|
|||||||
"creatingdefaultview": "",
|
"creatingdefaultview": "",
|
||||||
"duplicate_insurance_company": "",
|
"duplicate_insurance_company": "",
|
||||||
"loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.",
|
"loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.",
|
||||||
"saving": ""
|
"saving": "",
|
||||||
|
"task_preset_allocation_exceeded": ""
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"ReceivableCustomField": "",
|
"ReceivableCustomField": "",
|
||||||
@@ -519,6 +520,7 @@
|
|||||||
"list-active": "",
|
"list-active": "",
|
||||||
"list-all": "",
|
"list-all": "",
|
||||||
"list-ready": "",
|
"list-ready": "",
|
||||||
|
"manual-line": "",
|
||||||
"partsqueue": "",
|
"partsqueue": "",
|
||||||
"void": ""
|
"void": ""
|
||||||
},
|
},
|
||||||
@@ -1074,7 +1076,36 @@
|
|||||||
"earlyrorequired.message": ""
|
"earlyrorequired.message": ""
|
||||||
},
|
},
|
||||||
"labels": {
|
"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": {
|
"documents": {
|
||||||
@@ -1145,12 +1176,28 @@
|
|||||||
"new": "",
|
"new": "",
|
||||||
"newmember": ""
|
"newmember": ""
|
||||||
},
|
},
|
||||||
|
"errors": {
|
||||||
|
"allocation_total_exact": "",
|
||||||
|
"duplicate_member": "",
|
||||||
|
"minimum_one_member": ""
|
||||||
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"active": "",
|
"active": "",
|
||||||
|
"allocation": "",
|
||||||
|
"allocation_percentage": "",
|
||||||
"employeeid": "",
|
"employeeid": "",
|
||||||
"max_load": "",
|
"max_load": "",
|
||||||
"name": "",
|
"name": "",
|
||||||
|
"payout_method": "",
|
||||||
"percentage": ""
|
"percentage": ""
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"allocation_total": ""
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"commission": "",
|
||||||
|
"commission_percentage": "",
|
||||||
|
"hourly": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"employees": {
|
"employees": {
|
||||||
@@ -1266,6 +1313,7 @@
|
|||||||
"delete": "Effacer",
|
"delete": "Effacer",
|
||||||
"deleteall": "",
|
"deleteall": "",
|
||||||
"deselectall": "",
|
"deselectall": "",
|
||||||
|
"done": "",
|
||||||
"download": "",
|
"download": "",
|
||||||
"edit": "modifier",
|
"edit": "modifier",
|
||||||
"gotoadmin": "",
|
"gotoadmin": "",
|
||||||
@@ -3344,7 +3392,9 @@
|
|||||||
"work_in_progress_committed_labour": "",
|
"work_in_progress_committed_labour": "",
|
||||||
"work_in_progress_jobs": "",
|
"work_in_progress_jobs": "",
|
||||||
"work_in_progress_labour": "",
|
"work_in_progress_labour": "",
|
||||||
"work_in_progress_payables": ""
|
"work_in_progress_labour_summary": "",
|
||||||
|
"work_in_progress_payables": "",
|
||||||
|
"work_in_progress_payables_summary": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -3561,6 +3611,7 @@
|
|||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"actualhrs": "",
|
"actualhrs": "",
|
||||||
|
"amount": "",
|
||||||
"ciecacode": "",
|
"ciecacode": "",
|
||||||
"clockhours": "",
|
"clockhours": "",
|
||||||
"clockoff": "",
|
"clockoff": "",
|
||||||
@@ -3575,7 +3626,10 @@
|
|||||||
"employee_team": "",
|
"employee_team": "",
|
||||||
"flat_rate": "",
|
"flat_rate": "",
|
||||||
"memo": "",
|
"memo": "",
|
||||||
|
"pay": "",
|
||||||
|
"payout_method": "",
|
||||||
"productivehrs": "",
|
"productivehrs": "",
|
||||||
|
"rate": "",
|
||||||
"ro_number": "",
|
"ro_number": "",
|
||||||
"task_name": ""
|
"task_name": ""
|
||||||
},
|
},
|
||||||
@@ -3594,6 +3648,10 @@
|
|||||||
"lunch": "",
|
"lunch": "",
|
||||||
"new": "",
|
"new": "",
|
||||||
"payrollclaimedtasks": "",
|
"payrollclaimedtasks": "",
|
||||||
|
"payout_methods": {
|
||||||
|
"commission": "",
|
||||||
|
"hourly": ""
|
||||||
|
},
|
||||||
"pmbreak": "",
|
"pmbreak": "",
|
||||||
"pmshift": "",
|
"pmshift": "",
|
||||||
"shift": "",
|
"shift": "",
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import { RetryLink } from "@apollo/client/link/retry";
|
|||||||
import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
|
import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
|
||||||
import { getMainDefinition } from "@apollo/client/utilities";
|
import { getMainDefinition } from "@apollo/client/utilities";
|
||||||
|
|
||||||
import apolloLogger from "apollo-link-logger";
|
import apolloLoggerModule from "apollo-link-logger";
|
||||||
import { createClient } from "graphql-ws";
|
import { createClient } from "graphql-ws";
|
||||||
import { map } from "rxjs/operators";
|
import { map } from "rxjs/operators";
|
||||||
|
|
||||||
import { auth } from "../firebase/firebase.utils";
|
import { auth } from "../firebase/firebase.utils";
|
||||||
import errorLink from "../graphql/apollo-error-handling";
|
import errorLink from "../graphql/apollo-error-handling";
|
||||||
|
|
||||||
|
const apolloLogger = apolloLoggerModule?.default ?? apolloLoggerModule;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP transport
|
* HTTP transport
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1717,6 +1717,20 @@ export const TemplateList = (type, context) => {
|
|||||||
group: "jobs",
|
group: "jobs",
|
||||||
featureNameRestricted: "timetickets"
|
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: {
|
work_in_progress_committed_labour: {
|
||||||
title: i18n.t("reportcenter.templates.work_in_progress_committed_labour"),
|
title: i18n.t("reportcenter.templates.work_in_progress_committed_labour"),
|
||||||
description: "",
|
description: "",
|
||||||
@@ -1746,6 +1760,20 @@ export const TemplateList = (type, context) => {
|
|||||||
group: "jobs",
|
group: "jobs",
|
||||||
featureNameRestricted: "bills"
|
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: {
|
lag_time: {
|
||||||
title: i18n.t("reportcenter.templates.lag_time"),
|
title: i18n.t("reportcenter.templates.lag_time"),
|
||||||
description: "",
|
description: "",
|
||||||
|
|||||||
@@ -2156,10 +2156,12 @@
|
|||||||
- active:
|
- active:
|
||||||
_eq: true
|
_eq: true
|
||||||
columns:
|
columns:
|
||||||
|
- commission_rates
|
||||||
- created_at
|
- created_at
|
||||||
- employeeid
|
- employeeid
|
||||||
- id
|
- id
|
||||||
- labor_rates
|
- labor_rates
|
||||||
|
- payout_method
|
||||||
- percentage
|
- percentage
|
||||||
- teamid
|
- teamid
|
||||||
- updated_at
|
- updated_at
|
||||||
@@ -2167,10 +2169,12 @@
|
|||||||
- role: user
|
- role: user
|
||||||
permission:
|
permission:
|
||||||
columns:
|
columns:
|
||||||
|
- commission_rates
|
||||||
- created_at
|
- created_at
|
||||||
- employeeid
|
- employeeid
|
||||||
- id
|
- id
|
||||||
- labor_rates
|
- labor_rates
|
||||||
|
- payout_method
|
||||||
- percentage
|
- percentage
|
||||||
- teamid
|
- teamid
|
||||||
- updated_at
|
- updated_at
|
||||||
@@ -2188,10 +2192,12 @@
|
|||||||
- role: user
|
- role: user
|
||||||
permission:
|
permission:
|
||||||
columns:
|
columns:
|
||||||
|
- commission_rates
|
||||||
- created_at
|
- created_at
|
||||||
- employeeid
|
- employeeid
|
||||||
- id
|
- id
|
||||||
- labor_rates
|
- labor_rates
|
||||||
|
- payout_method
|
||||||
- percentage
|
- percentage
|
||||||
- teamid
|
- teamid
|
||||||
- updated_at
|
- updated_at
|
||||||
@@ -6506,6 +6512,7 @@
|
|||||||
- id
|
- id
|
||||||
- jobid
|
- jobid
|
||||||
- memo
|
- memo
|
||||||
|
- payout_context
|
||||||
- productivehrs
|
- productivehrs
|
||||||
- rate
|
- rate
|
||||||
- task_name
|
- task_name
|
||||||
@@ -6531,6 +6538,7 @@
|
|||||||
- id
|
- id
|
||||||
- jobid
|
- jobid
|
||||||
- memo
|
- memo
|
||||||
|
- payout_context
|
||||||
- productivehrs
|
- productivehrs
|
||||||
- rate
|
- rate
|
||||||
- task_name
|
- task_name
|
||||||
@@ -6565,6 +6573,7 @@
|
|||||||
- id
|
- id
|
||||||
- jobid
|
- jobid
|
||||||
- memo
|
- memo
|
||||||
|
- payout_context
|
||||||
- productivehrs
|
- productivehrs
|
||||||
- rate
|
- rate
|
||||||
- task_name
|
- task_name
|
||||||
@@ -6748,6 +6757,7 @@
|
|||||||
- id
|
- id
|
||||||
- jobid
|
- jobid
|
||||||
- memo
|
- memo
|
||||||
|
- payout_context
|
||||||
- productivehrs
|
- productivehrs
|
||||||
- rate
|
- rate
|
||||||
- updated_at
|
- updated_at
|
||||||
@@ -6768,6 +6778,7 @@
|
|||||||
- id
|
- id
|
||||||
- jobid
|
- jobid
|
||||||
- memo
|
- memo
|
||||||
|
- payout_context
|
||||||
- productivehrs
|
- productivehrs
|
||||||
- rate
|
- rate
|
||||||
- updated_at
|
- updated_at
|
||||||
@@ -6798,6 +6809,7 @@
|
|||||||
- id
|
- id
|
||||||
- jobid
|
- jobid
|
||||||
- memo
|
- memo
|
||||||
|
- payout_context
|
||||||
- productivehrs
|
- productivehrs
|
||||||
- rate
|
- rate
|
||||||
- updated_at
|
- updated_at
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
alter table "public"."employee_team_members" add column "payout_method" text
|
||||||
|
null;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
alter table "public"."employee_team_members" add column "commission_rates" jsonb
|
||||||
|
null;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
alter table "public"."timetickets" add column "payout_context" jsonb
|
||||||
|
null;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
alter table "public"."tt_approval_queue" add column "payout_context" jsonb
|
||||||
|
null;
|
||||||
2375
package-lock.json
generated
2375
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
47
package.json
47
package.json
@@ -18,25 +18,26 @@
|
|||||||
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
|
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-cloudwatch-logs": "^3.997.0",
|
"@aws-sdk/client-cloudwatch-logs": "^3.1009.0",
|
||||||
"@aws-sdk/client-elasticache": "^3.997.0",
|
"@aws-sdk/client-elasticache": "^3.1009.0",
|
||||||
"@aws-sdk/client-s3": "^3.997.0",
|
"@aws-sdk/client-s3": "^3.1009.0",
|
||||||
"@aws-sdk/client-secrets-manager": "^3.997.0",
|
"@aws-sdk/client-secrets-manager": "^3.1009.0",
|
||||||
"@aws-sdk/client-ses": "^3.997.0",
|
"@aws-sdk/client-ses": "^3.1009.0",
|
||||||
"@aws-sdk/client-sqs": "^3.997.0",
|
"@aws-sdk/client-sqs": "^3.1009.0",
|
||||||
"@aws-sdk/client-textract": "^3.997.0",
|
"@aws-sdk/client-textract": "^3.1009.0",
|
||||||
"@aws-sdk/credential-provider-node": "^3.972.12",
|
"@aws-sdk/credential-provider-node": "^3.972.21",
|
||||||
"@aws-sdk/lib-storage": "^3.997.0",
|
"@aws-sdk/lib-storage": "^3.1009.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.997.0",
|
"@aws-sdk/s3-request-presigner": "^3.1009.0",
|
||||||
|
"@jsreport/nodejs-client": "^4.1.0",
|
||||||
"@opensearch-project/opensearch": "^2.13.0",
|
"@opensearch-project/opensearch": "^2.13.0",
|
||||||
"@socket.io/admin-ui": "^0.5.1",
|
"@socket.io/admin-ui": "^0.5.1",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"aws4": "^1.13.2",
|
"aws4": "^1.13.2",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.6",
|
||||||
"axios-curlirize": "^2.0.0",
|
"axios-curlirize": "^2.0.0",
|
||||||
"better-queue": "^3.8.12",
|
"better-queue": "^3.8.12",
|
||||||
"bullmq": "^5.70.1",
|
"bullmq": "^5.71.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"cloudinary": "^2.9.0",
|
"cloudinary": "^2.9.0",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
@@ -46,20 +47,20 @@
|
|||||||
"dinero.js": "^1.9.1",
|
"dinero.js": "^1.9.1",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"fast-xml-parser": "^5.4.1",
|
"fast-xml-parser": "^5.5.6",
|
||||||
"firebase-admin": "^13.6.1",
|
"firebase-admin": "^13.7.0",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"graphql": "^16.13.0",
|
"graphql": "^16.13.1",
|
||||||
"graphql-request": "^6.1.0",
|
"graphql-request": "^7.4.0",
|
||||||
"intuit-oauth": "^4.2.2",
|
"intuit-oauth": "^4.2.2",
|
||||||
"ioredis": "^5.9.3",
|
"ioredis": "^5.10.0",
|
||||||
"json-2-csv": "^5.5.10",
|
"json-2-csv": "^5.5.10",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"juice": "^11.1.1",
|
"juice": "^11.1.1",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"moment-timezone": "^0.6.0",
|
"moment-timezone": "^0.6.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.1.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
"node-persist": "^4.0.4",
|
"node-persist": "^4.0.4",
|
||||||
"nodemailer": "^6.10.0",
|
"nodemailer": "^6.10.0",
|
||||||
@@ -69,15 +70,15 @@
|
|||||||
"recursive-diff": "^1.0.9",
|
"recursive-diff": "^1.0.9",
|
||||||
"rimraf": "^6.1.3",
|
"rimraf": "^6.1.3",
|
||||||
"skia-canvas": "^3.0.8",
|
"skia-canvas": "^3.0.8",
|
||||||
"soap": "^1.7.1",
|
"soap": "^1.8.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"socket.io-adapter": "^2.5.6",
|
"socket.io-adapter": "^2.5.6",
|
||||||
"ssh2-sftp-client": "^11.0.0",
|
"ssh2-sftp-client": "^11.0.0",
|
||||||
"twilio": "^5.12.2",
|
"twilio": "^5.13.0",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
"winston-cloudwatch": "^6.3.0",
|
"winston-cloudwatch": "^6.3.0",
|
||||||
"xml-formatter": "^3.6.7",
|
"xml-formatter": "^3.7.0",
|
||||||
"xml2js": "^0.6.2",
|
"xml2js": "^0.6.2",
|
||||||
"xmlbuilder2": "^4.0.3",
|
"xmlbuilder2": "^4.0.3",
|
||||||
"yazl": "^3.3.1"
|
"yazl": "^3.3.1"
|
||||||
@@ -86,11 +87,11 @@
|
|||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"globals": "^17.3.0",
|
"globals": "^17.4.0",
|
||||||
"mock-require": "^3.0.3",
|
"mock-require": "^3.0.3",
|
||||||
"p-limit": "^3.1.0",
|
"p-limit": "^3.1.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"supertest": "^7.2.2",
|
"supertest": "^7.2.2",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,12 +130,13 @@ exports.default = async (req, res) => {
|
|||||||
|
|
||||||
async function QueryVendorRecord(oauthClient, qbo_realmId, req, bill) {
|
async function QueryVendorRecord(oauthClient, qbo_realmId, req, bill) {
|
||||||
try {
|
try {
|
||||||
|
const url = urlBuilder(
|
||||||
|
qbo_realmId,
|
||||||
|
"query",
|
||||||
|
`select * From vendor where DisplayName = '${StandardizeName(bill.vendor.name)}'`
|
||||||
|
);
|
||||||
const result = await oauthClient.makeApiCall({
|
const result = await oauthClient.makeApiCall({
|
||||||
url: urlBuilder(
|
url: url,
|
||||||
qbo_realmId,
|
|
||||||
"query",
|
|
||||||
`select * From vendor where DisplayName = '${StandardizeName(bill.vendor.name)}'`
|
|
||||||
),
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -150,6 +151,11 @@ async function QueryVendorRecord(oauthClient, qbo_realmId, req, bill) {
|
|||||||
bodyshopid: bill.job.shopid,
|
bodyshopid: bill.job.shopid,
|
||||||
email: req.user.email
|
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];
|
return result.json?.QueryResponse?.Vendor?.[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -167,8 +173,9 @@ async function InsertVendorRecord(oauthClient, qbo_realmId, req, bill) {
|
|||||||
DisplayName: StandardizeName(bill.vendor.name)
|
DisplayName: StandardizeName(bill.vendor.name)
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
const url = urlBuilder(qbo_realmId, "vendor");
|
||||||
const result = await oauthClient.makeApiCall({
|
const result = await oauthClient.makeApiCall({
|
||||||
url: urlBuilder(qbo_realmId, "vendor"),
|
url: url,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -184,6 +191,12 @@ async function InsertVendorRecord(oauthClient, qbo_realmId, req, bill) {
|
|||||||
bodyshopid: bill.job.shopid,
|
bodyshopid: bill.job.shopid,
|
||||||
email: req.user.email
|
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) {
|
if (result.status >= 400) {
|
||||||
throw new Error(JSON.stringify(result.json.Fault));
|
throw new Error(JSON.stringify(result.json.Fault));
|
||||||
@@ -274,11 +287,12 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
|
|||||||
VendorRef: {
|
VendorRef: {
|
||||||
value: vendor.Id
|
value: vendor.Id
|
||||||
},
|
},
|
||||||
...(vendor.TermRef && !bill.is_credit_memo && {
|
...(vendor.TermRef &&
|
||||||
SalesTermRef: {
|
!bill.is_credit_memo && {
|
||||||
value: vendor.TermRef.value
|
SalesTermRef: {
|
||||||
}
|
value: vendor.TermRef.value
|
||||||
}),
|
}
|
||||||
|
}),
|
||||||
TxnDate: moment(bill.date)
|
TxnDate: moment(bill.date)
|
||||||
//.tz(bill.job.bodyshop.timezone)
|
//.tz(bill.job.bodyshop.timezone)
|
||||||
.format("YYYY-MM-DD"),
|
.format("YYYY-MM-DD"),
|
||||||
@@ -318,8 +332,9 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
|
|||||||
[logKey]: logValue
|
[logKey]: logValue
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
|
const url = urlBuilder(qbo_realmId, bill.is_credit_memo ? "vendorcredit" : "bill");
|
||||||
const result = await oauthClient.makeApiCall({
|
const result = await oauthClient.makeApiCall({
|
||||||
url: urlBuilder(qbo_realmId, bill.is_credit_memo ? "vendorcredit" : "bill"),
|
url: url,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -335,6 +350,12 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
|
|||||||
bodyshopid: bill.job.shopid,
|
bodyshopid: bill.job.shopid,
|
||||||
email: req.user.email
|
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) {
|
if (result.status >= 400) {
|
||||||
throw new Error(JSON.stringify(result.json.Fault));
|
throw new Error(JSON.stringify(result.json.Fault));
|
||||||
|
|||||||
@@ -82,14 +82,7 @@ exports.default = async (req, res) => {
|
|||||||
|
|
||||||
if (isThreeTier || (!isThreeTier && twoTierPref === "name")) {
|
if (isThreeTier || (!isThreeTier && twoTierPref === "name")) {
|
||||||
//Insert the name/owner and account for whether the source should be the ins co in 3 tier..
|
//Insert the name/owner and account for whether the source should be the ins co in 3 tier..
|
||||||
ownerCustomerTier = await QueryOwner(
|
ownerCustomerTier = await QueryOwner(oauthClient, qbo_realmId, req, payment.job, insCoCustomerTier);
|
||||||
oauthClient,
|
|
||||||
qbo_realmId,
|
|
||||||
req,
|
|
||||||
payment.job,
|
|
||||||
isThreeTier,
|
|
||||||
insCoCustomerTier
|
|
||||||
);
|
|
||||||
//Query for the owner itself.
|
//Query for the owner itself.
|
||||||
if (!ownerCustomerTier) {
|
if (!ownerCustomerTier) {
|
||||||
ownerCustomerTier = await InsertOwner(
|
ownerCustomerTier = await InsertOwner(
|
||||||
@@ -229,8 +222,9 @@ async function InsertPayment(oauthClient, qbo_realmId, req, payment, parentRef)
|
|||||||
paymentQbo
|
paymentQbo
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
|
const url = urlBuilder(qbo_realmId, "payment");
|
||||||
const result = await oauthClient.makeApiCall({
|
const result = await oauthClient.makeApiCall({
|
||||||
url: urlBuilder(qbo_realmId, "payment"),
|
url: url,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -246,6 +240,12 @@ async function InsertPayment(oauthClient, qbo_realmId, req, payment, parentRef)
|
|||||||
bodyshopid: payment.job.shopid,
|
bodyshopid: payment.job.shopid,
|
||||||
email: req.user.email
|
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) {
|
if (result.status >= 400) {
|
||||||
throw new Error(JSON.stringify(result.json.Fault));
|
throw new Error(JSON.stringify(result.json.Fault));
|
||||||
@@ -428,8 +428,9 @@ async function InsertCreditMemo(oauthClient, qbo_realmId, req, payment, parentRe
|
|||||||
paymentQbo
|
paymentQbo
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
|
const url = urlBuilder(qbo_realmId, "creditmemo");
|
||||||
const result = await oauthClient.makeApiCall({
|
const result = await oauthClient.makeApiCall({
|
||||||
url: urlBuilder(qbo_realmId, "creditmemo"),
|
url: url,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -445,6 +446,12 @@ async function InsertCreditMemo(oauthClient, qbo_realmId, req, payment, parentRe
|
|||||||
bodyshopid: req.user.bodyshopid,
|
bodyshopid: req.user.bodyshopid,
|
||||||
email: req.user.email
|
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) {
|
if (result.status >= 400) {
|
||||||
throw new Error(JSON.stringify(result.json.Fault));
|
throw new Error(JSON.stringify(result.json.Fault));
|
||||||
|
|||||||
@@ -213,12 +213,13 @@ exports.default = async (req, res) => {
|
|||||||
|
|
||||||
async function QueryInsuranceCo(oauthClient, qbo_realmId, req, job) {
|
async function QueryInsuranceCo(oauthClient, qbo_realmId, req, job) {
|
||||||
try {
|
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({
|
const result = await oauthClient.makeApiCall({
|
||||||
url: urlBuilder(
|
url: url,
|
||||||
qbo_realmId,
|
|
||||||
"query",
|
|
||||||
`select * From Customer where DisplayName = '${StandardizeName(job.ins_co_nm.trim())}' and Active = true`
|
|
||||||
),
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -233,6 +234,11 @@ async function QueryInsuranceCo(oauthClient, qbo_realmId, req, job) {
|
|||||||
jobid: job.id,
|
jobid: job.id,
|
||||||
email: req.user.email
|
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];
|
return result.json?.QueryResponse?.Customer?.[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -266,8 +272,9 @@ async function InsertInsuranceCo(oauthClient, qbo_realmId, req, job, bodyshop) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
const url = urlBuilder(qbo_realmId, "customer");
|
||||||
const result = await oauthClient.makeApiCall({
|
const result = await oauthClient.makeApiCall({
|
||||||
url: urlBuilder(qbo_realmId, "customer"),
|
url: url,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -283,6 +290,12 @@ async function InsertInsuranceCo(oauthClient, qbo_realmId, req, job, bodyshop) {
|
|||||||
jobid: job.id,
|
jobid: job.id,
|
||||||
email: req.user.email
|
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;
|
return result.json?.Customer;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -298,12 +311,13 @@ exports.InsertInsuranceCo = InsertInsuranceCo;
|
|||||||
|
|
||||||
async function QueryOwner(oauthClient, qbo_realmId, req, job, parentTierRef) {
|
async function QueryOwner(oauthClient, qbo_realmId, req, job, parentTierRef) {
|
||||||
const ownerName = generateOwnerTier(job, true, null);
|
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({
|
const result = await oauthClient.makeApiCall({
|
||||||
url: urlBuilder(
|
url: url,
|
||||||
qbo_realmId,
|
|
||||||
"query",
|
|
||||||
`select * From Customer where DisplayName = '${StandardizeName(ownerName)}' and Active = true`
|
|
||||||
),
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -318,6 +332,11 @@ async function QueryOwner(oauthClient, qbo_realmId, req, job, parentTierRef) {
|
|||||||
jobid: job.id,
|
jobid: job.id,
|
||||||
email: req.user.email
|
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);
|
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 {
|
try {
|
||||||
|
const url = urlBuilder(qbo_realmId, "customer");
|
||||||
const result = await oauthClient.makeApiCall({
|
const result = await oauthClient.makeApiCall({
|
||||||
url: urlBuilder(qbo_realmId, "customer"),
|
url: url,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -364,6 +384,12 @@ async function InsertOwner(oauthClient, qbo_realmId, req, job, isThreeTier, pare
|
|||||||
jobid: job.id,
|
jobid: job.id,
|
||||||
email: req.user.email
|
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;
|
return result.json?.Customer;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -378,12 +404,13 @@ async function InsertOwner(oauthClient, qbo_realmId, req, job, isThreeTier, pare
|
|||||||
exports.InsertOwner = InsertOwner;
|
exports.InsertOwner = InsertOwner;
|
||||||
|
|
||||||
async function QueryJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
|
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({
|
const result = await oauthClient.makeApiCall({
|
||||||
url: urlBuilder(
|
url: url,
|
||||||
qbo_realmId,
|
|
||||||
"query",
|
|
||||||
`select * From Customer where DisplayName = '${job.ro_number}' and Active = true`
|
|
||||||
),
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -398,6 +425,11 @@ async function QueryJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
|
|||||||
jobid: job.id,
|
jobid: job.id,
|
||||||
email: req.user.email
|
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;
|
const customers = result.json?.QueryResponse?.Customer;
|
||||||
return customers && (parentTierRef ? customers.find((x) => x.ParentRef.value === parentTierRef.Id) : customers[0]);
|
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 {
|
try {
|
||||||
|
const url = urlBuilder(qbo_realmId, "customer");
|
||||||
const result = await oauthClient.makeApiCall({
|
const result = await oauthClient.makeApiCall({
|
||||||
url: urlBuilder(qbo_realmId, "customer"),
|
url: url,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -440,6 +473,12 @@ async function InsertJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
|
|||||||
jobid: job.id,
|
jobid: job.id,
|
||||||
email: req.user.email
|
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) {
|
if (result.status >= 400) {
|
||||||
throw new Error(JSON.stringify(result.json.Fault));
|
throw new Error(JSON.stringify(result.json.Fault));
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
|
||||||
|
|
||||||
const Fuse = require('fuse.js');
|
const Fuse = require('fuse.js');
|
||||||
const { has } = require("lodash");
|
|
||||||
const { standardizedFieldsnames } = require('./bill-ocr-normalize');
|
const { standardizedFieldsnames } = require('./bill-ocr-normalize');
|
||||||
const InstanceManager = require("../../utils/instanceMgr").default;
|
const InstanceManager = require("../../utils/instanceMgr").default;
|
||||||
|
|
||||||
const PRICE_PERCENT_MARGIN_TOLERANCE = 0.5; //Used to make sure prices and costs are likely.
|
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
|
// Helper function to normalize fields
|
||||||
const normalizePartNumber = (str) => {
|
const normalizePartNumber = (str) => {
|
||||||
return str.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
return str.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||||||
@@ -17,7 +17,38 @@ const normalizeText = (str) => {
|
|||||||
};
|
};
|
||||||
const normalizePrice = (str) => {
|
const normalizePrice = (str) => {
|
||||||
if (typeof str !== 'string') return 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.
|
//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 hasActualCost = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.actual_cost);
|
||||||
const hasActualPrice = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.actual_price);
|
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 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
|
// Calculate weighted average, giving more weight to important fields
|
||||||
// If we can identify key fields (ITEM, PRODUCT_CODE, PRICE), weight them higher
|
// If we can identify key fields (ITEM, PRODUCT_CODE, PRICE), weight them higher
|
||||||
@@ -173,10 +205,11 @@ const calculateTextractConfidence = (textractLineItem) => {
|
|||||||
if (!hasActualCost) missingCount++;
|
if (!hasActualCost) missingCount++;
|
||||||
if (!hasActualPrice) missingCount++;
|
if (!hasActualPrice) missingCount++;
|
||||||
if (!hasLineDesc) missingCount++;
|
if (!hasLineDesc) missingCount++;
|
||||||
|
if (!hasQuantity) missingCount++;
|
||||||
|
|
||||||
// Each missing field reduces confidence by 15%
|
// Each missing field reduces confidence by 20%
|
||||||
if (missingCount > 0) {
|
if (missingCount > 0) {
|
||||||
missingFieldsPenalty = 1.0 - (missingCount * 0.15);
|
missingFieldsPenalty = 1.0 - (missingCount * 0.20);
|
||||||
}
|
}
|
||||||
|
|
||||||
avgConfidence = avgConfidence * missingFieldsPenalty;
|
avgConfidence = avgConfidence * missingFieldsPenalty;
|
||||||
@@ -361,16 +394,16 @@ async function generateBillFormData({ processedData, jobid: jobidFromProps, body
|
|||||||
const joblineMatches = joblineFuzzySearch({ fuseToSearch: jobLineDescFuse, processedData });
|
const joblineMatches = joblineFuzzySearch({ fuseToSearch: jobLineDescFuse, processedData });
|
||||||
|
|
||||||
const vendorFuse = new Fuse(
|
const vendorFuse = new Fuse(
|
||||||
jobData.vendors,
|
jobData.vendors.map(v => ({ ...v, name_normalized: normalizeText(v.name) })),
|
||||||
{
|
{
|
||||||
keys: ['name'],
|
keys: [{ name: "name", weight: 3 }, { name: 'name_normalized', weight: 2 }],
|
||||||
threshold: 0.4, //Adjust as needed for matching sensitivity,
|
threshold: 0.4,
|
||||||
includeScore: true,
|
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;
|
let vendorid;
|
||||||
if (vendorMatches.length > 0) {
|
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.');
|
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?
|
//TODO: How do we handle freight lines and core charges?
|
||||||
//Create the form data structure for the bill posting screen.
|
//Create the form data structure for the bill posting screen.
|
||||||
const billFormData = {
|
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
|
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)
|
//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 = {
|
const lineObject = {
|
||||||
@@ -714,5 +787,6 @@ const bodyshopHasDmsKey = (bodyshop) =>
|
|||||||
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
generateBillFormData
|
generateBillFormData,
|
||||||
|
normalizePrice
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,10 +50,12 @@ function normalizeLabelName(labelText) {
|
|||||||
'unit_price': standardizedFieldsnames.actual_price,
|
'unit_price': standardizedFieldsnames.actual_price,
|
||||||
'list': standardizedFieldsnames.actual_price,
|
'list': standardizedFieldsnames.actual_price,
|
||||||
'retail_price': standardizedFieldsnames.actual_price,
|
'retail_price': standardizedFieldsnames.actual_price,
|
||||||
|
'retail': standardizedFieldsnames.actual_price,
|
||||||
'net': standardizedFieldsnames.actual_cost,
|
'net': standardizedFieldsnames.actual_cost,
|
||||||
'selling_price': standardizedFieldsnames.actual_cost,
|
'selling_price': standardizedFieldsnames.actual_cost,
|
||||||
'net_price': standardizedFieldsnames.actual_cost,
|
'net_price': standardizedFieldsnames.actual_cost,
|
||||||
'net_cost': standardizedFieldsnames.actual_cost,
|
'net_cost': standardizedFieldsnames.actual_cost,
|
||||||
|
'total': standardizedFieldsnames.actual_cost,
|
||||||
'po_no': standardizedFieldsnames.ro_number,
|
'po_no': standardizedFieldsnames.ro_number,
|
||||||
'customer_po_no': standardizedFieldsnames.ro_number,
|
'customer_po_no': standardizedFieldsnames.ro_number,
|
||||||
'customer_po_no_': standardizedFieldsnames.ro_number
|
'customer_po_no_': standardizedFieldsnames.ro_number
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const { getTextractJobKey, setTextractJob, getTextractJob, getFileType, getPdfPa
|
|||||||
const { extractInvoiceData, processScanData } = require("./bill-ocr-normalize");
|
const { extractInvoiceData, processScanData } = require("./bill-ocr-normalize");
|
||||||
const { generateBillFormData } = require("./bill-ocr-generator");
|
const { generateBillFormData } = require("./bill-ocr-generator");
|
||||||
const logger = require("../../utils/logger");
|
const logger = require("../../utils/logger");
|
||||||
|
const _ = require("lodash");
|
||||||
|
|
||||||
// Initialize AWS clients
|
// Initialize AWS clients
|
||||||
const awsConfig = {
|
const awsConfig = {
|
||||||
@@ -66,7 +67,7 @@ async function handleBillOcr(req, res) {
|
|||||||
if (fileType === 'image') {
|
if (fileType === 'image') {
|
||||||
const processedData = await processSinglePageDocument(uploadedFile.buffer);
|
const processedData = await processSinglePageDocument(uploadedFile.buffer);
|
||||||
const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: req });
|
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({
|
return res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -82,7 +83,7 @@ async function handleBillOcr(req, res) {
|
|||||||
// Process synchronously for single-page documents
|
// Process synchronously for single-page documents
|
||||||
const processedData = await processSinglePageDocument(uploadedFile.buffer);
|
const processedData = await processSinglePageDocument(uploadedFile.buffer);
|
||||||
const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: req });
|
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({
|
return res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
status: 'COMPLETED',
|
status: 'COMPLETED',
|
||||||
|
|||||||
@@ -66,7 +66,12 @@ exports.default = async function ReloadCdkMakes(req, res) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log("cdk-replace-makes-models-error", "ERROR", req.user.email, null, {
|
logger.log("cdk-replace-makes-models-error", "ERROR", req.user.email, null, {
|
||||||
cdk_dealerid,
|
cdk_dealerid,
|
||||||
error
|
error: {
|
||||||
|
message: error?.message,
|
||||||
|
stack: error?.stack,
|
||||||
|
name: error?.name,
|
||||||
|
code: error?.code
|
||||||
|
}
|
||||||
});
|
});
|
||||||
res.status(500).json(error);
|
res.status(500).json(error);
|
||||||
}
|
}
|
||||||
@@ -105,7 +110,12 @@ async function GetCdkMakes(req, cdk_dealerid) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log("cdk-replace-makes-models-error", "ERROR", req.user.email, null, {
|
logger.log("cdk-replace-makes-models-error", "ERROR", req.user.email, null, {
|
||||||
cdk_dealerid,
|
cdk_dealerid,
|
||||||
error
|
error: {
|
||||||
|
message: error?.message,
|
||||||
|
stack: error?.stack,
|
||||||
|
name: error?.name,
|
||||||
|
code: error?.code
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
throw new Error(error);
|
throw new Error(error);
|
||||||
@@ -141,7 +151,12 @@ async function GetFortellisMakes(req, cdk_dealerid) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log("fortellis-replace-makes-models-error", "ERROR", req.user.email, null, {
|
logger.log("fortellis-replace-makes-models-error", "ERROR", req.user.email, null, {
|
||||||
cdk_dealerid,
|
cdk_dealerid,
|
||||||
error
|
error: {
|
||||||
|
message: error?.message,
|
||||||
|
stack: error?.stack,
|
||||||
|
name: error?.name,
|
||||||
|
code: error?.code
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
throw new Error(error);
|
throw new Error(error);
|
||||||
|
|||||||
@@ -250,6 +250,8 @@ const CreateRepairOrderTag = (job, errorCallback) => {
|
|||||||
},
|
},
|
||||||
InsuranceCompany: job.ins_co_nm || "",
|
InsuranceCompany: job.ins_co_nm || "",
|
||||||
Claim: job.clm_no || "",
|
Claim: job.clm_no || "",
|
||||||
|
Deductible: job.ded_amt || 0,
|
||||||
|
PolicyNo: job.policy_no || "",
|
||||||
DMSAllocation: job.dms_allocation || "",
|
DMSAllocation: job.dms_allocation || "",
|
||||||
Contacts: {
|
Contacts: {
|
||||||
CSR: job.employee_csr_rel
|
CSR: job.employee_csr_rel
|
||||||
|
|||||||
@@ -959,7 +959,7 @@ async function UpdateDmsVehicle({ socket, redisHelpers, JobData, DMSVeh, DMSCust
|
|||||||
delete DMSVehToSend.inventoryAccount;
|
delete DMSVehToSend.inventoryAccount;
|
||||||
|
|
||||||
!DMSVehToSend.vehicle.engineNumber && delete DMSVehToSend.vehicle.engineNumber;
|
!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;
|
!DMSVehToSend.vehicle.exteriorColor && delete DMSVehToSend.vehicle.exteriorColor;
|
||||||
|
|
||||||
const result = await MakeFortellisCall({
|
const result = await MakeFortellisCall({
|
||||||
|
|||||||
@@ -1285,6 +1285,7 @@ exports.KAIZEN_QUERY = `query KAIZEN_EXPORT($start: timestamptz, $bodyshopid: uu
|
|||||||
date_repairstarted
|
date_repairstarted
|
||||||
date_void
|
date_void
|
||||||
dms_allocation
|
dms_allocation
|
||||||
|
ded_amt
|
||||||
employee_body_rel {
|
employee_body_rel {
|
||||||
first_name
|
first_name
|
||||||
last_name
|
last_name
|
||||||
@@ -1380,6 +1381,7 @@ exports.KAIZEN_QUERY = `query KAIZEN_EXPORT($start: timestamptz, $bodyshopid: uu
|
|||||||
}
|
}
|
||||||
parts_tax_rates
|
parts_tax_rates
|
||||||
plate_no
|
plate_no
|
||||||
|
policy_no
|
||||||
rate_la1
|
rate_la1
|
||||||
rate_la2
|
rate_la2
|
||||||
rate_la3
|
rate_la3
|
||||||
@@ -2461,6 +2463,8 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
|
|||||||
}
|
}
|
||||||
percentage
|
percentage
|
||||||
labor_rates
|
labor_rates
|
||||||
|
payout_method
|
||||||
|
commission_rates
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2471,6 +2475,7 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
|
|||||||
productivehrs
|
productivehrs
|
||||||
actualhrs
|
actualhrs
|
||||||
ciecacode
|
ciecacode
|
||||||
|
payout_context
|
||||||
}
|
}
|
||||||
lbr_adjustments
|
lbr_adjustments
|
||||||
ro_number
|
ro_number
|
||||||
@@ -2562,6 +2567,8 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
|
|||||||
}
|
}
|
||||||
percentage
|
percentage
|
||||||
labor_rates
|
labor_rates
|
||||||
|
payout_method
|
||||||
|
commission_rates
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2572,6 +2579,7 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
|
|||||||
productivehrs
|
productivehrs
|
||||||
actualhrs
|
actualhrs
|
||||||
ciecacode
|
ciecacode
|
||||||
|
payout_context
|
||||||
}
|
}
|
||||||
lbr_adjustments
|
lbr_adjustments
|
||||||
ro_number
|
ro_number
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const sendPaymentNotificationEmail = require("./sendPaymentNotificationEmail");
|
const sendPaymentNotificationEmail = require("./sendPaymentNotificationEmail");
|
||||||
const { INSERT_NEW_PAYMENT, GET_BODYSHOP_BY_ID, GET_JOBS_BY_PKS } = require("../../graphql-client/queries");
|
const { INSERT_NEW_PAYMENT, GET_BODYSHOP_BY_ID, GET_JOBS_BY_PKS } = require("../../graphql-client/queries");
|
||||||
const getPaymentType = require("./getPaymentType");
|
const getPaymentType = require("./getPaymentType");
|
||||||
const moment = require("moment");
|
const moment = require("moment-timezone");
|
||||||
|
|
||||||
const gqlClient = require("../../graphql-client/graphql-client").client;
|
const gqlClient = require("../../graphql-client/graphql-client").client;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const {
|
|||||||
|
|
||||||
const { sendTaskEmail } = require("../../email/sendemail");
|
const { sendTaskEmail } = require("../../email/sendemail");
|
||||||
const getPaymentType = require("./getPaymentType");
|
const getPaymentType = require("./getPaymentType");
|
||||||
const moment = require("moment");
|
const moment = require("moment-timezone");
|
||||||
|
|
||||||
const gqlClient = require("../../graphql-client/graphql-client").client;
|
const gqlClient = require("../../graphql-client/graphql-client").client;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
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 getPaymentType = require("../getPaymentType");
|
||||||
const decodeComment = require("../decodeComment");
|
const decodeComment = require("../decodeComment");
|
||||||
const getCptellerUrl = require("../getCptellerUrl");
|
const getCptellerUrl = require("../getCptellerUrl");
|
||||||
@@ -145,28 +158,15 @@ describe("Payment Processing Functions", () => {
|
|||||||
// GetShopCredentials Tests
|
// GetShopCredentials Tests
|
||||||
describe("getShopCredentials", () => {
|
describe("getShopCredentials", () => {
|
||||||
const originalEnv = { ...process.env };
|
const originalEnv = { ...process.env };
|
||||||
let mockSend;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockSend = vi.fn();
|
mockSend.mockReset();
|
||||||
vi.mock("@aws-sdk/client-secrets-manager", () => {
|
|
||||||
return {
|
|
||||||
SecretsManagerClient: vi.fn(() => ({
|
|
||||||
send: mockSend
|
|
||||||
})),
|
|
||||||
GetSecretValueCommand: vi.fn((input) => input)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
process.env.INTELLIPAY_MERCHANTKEY = "test-merchant-key";
|
process.env.INTELLIPAY_MERCHANTKEY = "test-merchant-key";
|
||||||
process.env.INTELLIPAY_APIKEY = "test-api-key";
|
process.env.INTELLIPAY_APIKEY = "test-api-key";
|
||||||
vi.resetModules();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
vi.restoreAllMocks();
|
|
||||||
vi.unmock("@aws-sdk/client-secrets-manager");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns environment variables in non-production environment", async () => {
|
it("returns environment variables in non-production environment", async () => {
|
||||||
|
|||||||
@@ -315,7 +315,12 @@ function CalculateRatesTotals(ratesList) {
|
|||||||
if (item.mod_lbr_ty) {
|
if (item.mod_lbr_ty) {
|
||||||
//Check to see if it has 0 hours and a price instead.
|
//Check to see if it has 0 hours and a price instead.
|
||||||
//Extend for when there are hours and a price.
|
//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.
|
//Scenario where SGI may pay out hours using a part price.
|
||||||
if (!ret[item.mod_lbr_ty.toLowerCase()].total) {
|
if (!ret[item.mod_lbr_ty.toLowerCase()].total) {
|
||||||
ret[item.mod_lbr_ty.toLowerCase()].total = Dinero();
|
ret[item.mod_lbr_ty.toLowerCase()].total = Dinero();
|
||||||
@@ -339,38 +344,30 @@ function CalculateRatesTotals(ratesList) {
|
|||||||
let subtotal = Dinero({ amount: 0 });
|
let subtotal = Dinero({ amount: 0 });
|
||||||
let rates_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.
|
//Skip calculating mapa and mash if we got the amounts.
|
||||||
if (!((property === "mapa" && hasMapaLine) || (property === "mash" && hasMashLine))) {
|
const shouldSkipCalculation = (property === "mapa" && hasMapaLine) || (property === "mash" && hasMashLine);
|
||||||
if (!ret[property].total) {
|
|
||||||
ret[property].total = Dinero();
|
if (!shouldSkipCalculation) {
|
||||||
}
|
values.total ??= Dinero();
|
||||||
let threshold;
|
|
||||||
//Check if there is a max for this type.
|
//Check if there is a max for this type and apply it.
|
||||||
if (ratesList.materials && ratesList.materials[property]) {
|
const maxDollar =
|
||||||
//
|
ratesList.materials?.[property]?.cal_maxdlr || ratesList.materials?.[property.toUpperCase()]?.cal_maxdlr;
|
||||||
if (ratesList.materials[property].cal_maxdlr && ratesList.materials[property].cal_maxdlr > 0) {
|
const threshold = maxDollar > 0 ? Dinero({ amount: Math.round(maxDollar * 100) }) : null;
|
||||||
//It has an upper threshhold.
|
|
||||||
threshold = Dinero({
|
|
||||||
amount: Math.round(ratesList.materials[property].cal_maxdlr * 100)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = Dinero({
|
const total = Dinero({
|
||||||
amount: Math.round((ret[property].rate || 0) * 100)
|
amount: Math.round((values.rate || 0) * 100)
|
||||||
}).multiply(ret[property].hours);
|
}).multiply(values.hours);
|
||||||
|
|
||||||
if (threshold && total.greaterThanOrEqual(threshold)) {
|
values.total = values.total.add(threshold && total.greaterThanOrEqual(threshold) ? threshold : total);
|
||||||
ret[property].total = ret[property].total.add(threshold);
|
|
||||||
} else {
|
|
||||||
ret[property].total = ret[property].total.add(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;
|
ret.subtotal = subtotal;
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ describe("TotalsServerSide fixture tests", () => {
|
|||||||
|
|
||||||
const fixtureFiles = fs.readdirSync(fixturesDir).filter((f) => f.endsWith(".json"));
|
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 = {
|
const dummyClient = {
|
||||||
request: async () => {
|
request: async () => {
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -1,20 +1,9 @@
|
|||||||
const Dinero = require("dinero.js");
|
|
||||||
const queries = require("../graphql-client/queries");
|
const queries = require("../graphql-client/queries");
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
const { CalculateExpectedHoursForJob, CalculateTicketsHoursForJob } = require("./pay-all");
|
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) {
|
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);
|
logger.log("job-payroll-calculate-labor", "DEBUG", req.user.email, jobid, null);
|
||||||
|
|
||||||
const BearerToken = req.BearerToken;
|
const BearerToken = req.BearerToken;
|
||||||
@@ -41,23 +30,19 @@ exports.calculatelabor = async function (req, res) {
|
|||||||
Object.keys(employeeHash).forEach((employeeIdKey) => {
|
Object.keys(employeeHash).forEach((employeeIdKey) => {
|
||||||
//At the employee level.
|
//At the employee level.
|
||||||
Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => {
|
Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => {
|
||||||
//At the labor level
|
const expected = employeeHash[employeeIdKey][laborTypeKey];
|
||||||
Object.keys(employeeHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => {
|
const claimed = ticketHash?.[employeeIdKey]?.[laborTypeKey];
|
||||||
//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];
|
|
||||||
}
|
|
||||||
|
|
||||||
totals.push({
|
if (claimed) {
|
||||||
employeeid: employeeIdKey,
|
delete ticketHash[employeeIdKey][laborTypeKey];
|
||||||
rate: rateKey,
|
}
|
||||||
mod_lbr_ty: laborTypeKey,
|
|
||||||
expectedHours,
|
totals.push({
|
||||||
claimedHours: claimedHours || 0
|
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) => {
|
Object.keys(ticketHash).forEach((employeeIdKey) => {
|
||||||
//At the employee level.
|
//At the employee level.
|
||||||
Object.keys(ticketHash[employeeIdKey]).forEach((laborTypeKey) => {
|
Object.keys(ticketHash[employeeIdKey]).forEach((laborTypeKey) => {
|
||||||
//At the labor level
|
const claimed = ticketHash[employeeIdKey][laborTypeKey];
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
totals.push({
|
totals.push({
|
||||||
employeeid: employeeIdKey,
|
employeeid: employeeIdKey,
|
||||||
rate: rateKey,
|
rate: claimed.rate,
|
||||||
mod_lbr_ty: laborTypeKey,
|
mod_lbr_ty: laborTypeKey,
|
||||||
expectedHours,
|
expectedHours: 0,
|
||||||
claimedHours: claimedHours || 0
|
claimedHours: claimed.hours || 0
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -101,6 +77,6 @@ exports.calculatelabor = async function (req, res) {
|
|||||||
jobid: jobid,
|
jobid: jobid,
|
||||||
error
|
error
|
||||||
});
|
});
|
||||||
res.status(503).send();
|
res.status(400).json({ error: error.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,42 @@
|
|||||||
const Dinero = require("dinero.js");
|
|
||||||
const queries = require("../graphql-client/queries");
|
const queries = require("../graphql-client/queries");
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
const { CalculateExpectedHoursForJob } = require("./pay-all");
|
const { CalculateExpectedHoursForJob, RoundPayrollHours } = require("./pay-all");
|
||||||
const moment = require("moment");
|
const moment = require("moment");
|
||||||
// Dinero.defaultCurrency = "USD";
|
|
||||||
// Dinero.globalLocale = "en-CA";
|
const normalizePercent = (value) => Math.round((Number(value || 0) + Number.EPSILON) * 10000) / 10000;
|
||||||
Dinero.globalRoundingMode = "HALF_EVEN";
|
|
||||||
|
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) {
|
exports.claimtask = async function (req, res) {
|
||||||
const { jobid, task, calculateOnly, employee } = req.body;
|
const { jobid, task, calculateOnly, employee } = req.body;
|
||||||
@@ -21,12 +52,25 @@ exports.claimtask = async function (req, res) {
|
|||||||
id: jobid
|
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) {
|
if (!theTaskPreset) {
|
||||||
res.status(400).json({ success: false, error: "Provided task preset not found." });
|
res.status(400).json({ success: false, error: "Provided task preset not found." });
|
||||||
return;
|
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.
|
//Get all of the assignments that are filtered.
|
||||||
const { assignmentHash, employeeHash } = CalculateExpectedHoursForJob(job, theTaskPreset.hourstype);
|
const { assignmentHash, employeeHash } = CalculateExpectedHoursForJob(job, theTaskPreset.hourstype);
|
||||||
const ticketsToInsert = [];
|
const ticketsToInsert = [];
|
||||||
@@ -35,32 +79,37 @@ exports.claimtask = async function (req, res) {
|
|||||||
Object.keys(employeeHash).forEach((employeeIdKey) => {
|
Object.keys(employeeHash).forEach((employeeIdKey) => {
|
||||||
//At the employee level.
|
//At the employee level.
|
||||||
Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => {
|
Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => {
|
||||||
//At the labor level
|
const expected = employeeHash[employeeIdKey][laborTypeKey];
|
||||||
Object.keys(employeeHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => {
|
const expectedHours = RoundPayrollHours(expected.hours * (theTaskPreset.percent / 100));
|
||||||
//At the rate level.
|
|
||||||
const expectedHours = employeeHash[employeeIdKey][laborTypeKey][rateKey] * (theTaskPreset.percent / 100);
|
|
||||||
|
|
||||||
ticketsToInsert.push({
|
ticketsToInsert.push({
|
||||||
task_name: task,
|
task_name: task,
|
||||||
jobid: job.id,
|
jobid: job.id,
|
||||||
bodyshopid: job.bodyshop.id,
|
bodyshopid: job.bodyshop.id,
|
||||||
employeeid: employeeIdKey,
|
employeeid: employeeIdKey,
|
||||||
productivehrs: expectedHours,
|
productivehrs: expectedHours,
|
||||||
rate: rateKey,
|
rate: expected.rate,
|
||||||
ciecacode: laborTypeKey,
|
ciecacode: laborTypeKey,
|
||||||
flat_rate: true,
|
flat_rate: true,
|
||||||
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborTypeKey],
|
created_by: employee?.name || req.user.email,
|
||||||
memo: `*Flagged Task* ${theTaskPreset.memo}`
|
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) {
|
if (!calculateOnly) {
|
||||||
//Insert the time ticekts if we're not just calculating them.
|
//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)
|
timetickets: ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0)
|
||||||
});
|
});
|
||||||
const updateResult = await client.request(queries.UPDATE_JOB, {
|
await client.request(queries.UPDATE_JOB, {
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
job: {
|
job: {
|
||||||
status: theTaskPreset.nextstatus,
|
status: theTaskPreset.nextstatus,
|
||||||
@@ -82,6 +131,6 @@ exports.claimtask = async function (req, res) {
|
|||||||
jobid: jobid,
|
jobid: jobid,
|
||||||
error
|
error
|
||||||
});
|
});
|
||||||
res.status(503).send();
|
res.status(400).json({ success: false, error: error.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,196 @@
|
|||||||
const Dinero = require("dinero.js");
|
const Dinero = require("dinero.js");
|
||||||
const queries = require("../graphql-client/queries");
|
const queries = require("../graphql-client/queries");
|
||||||
const rdiff = require("recursive-diff");
|
|
||||||
|
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
|
|
||||||
// Dinero.defaultCurrency = "USD";
|
|
||||||
// Dinero.globalLocale = "en-CA";
|
|
||||||
Dinero.globalRoundingMode = "HALF_EVEN";
|
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) {
|
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);
|
logger.log("job-payroll-pay-all", "DEBUG", req.user.email, jobid, null);
|
||||||
|
|
||||||
const BearerToken = req.BearerToken;
|
const BearerToken = req.BearerToken;
|
||||||
@@ -22,253 +203,183 @@ exports.payall = async function (req, res) {
|
|||||||
id: jobid
|
id: jobid
|
||||||
});
|
});
|
||||||
|
|
||||||
//iterate over each ticket, building a hash of team -> employee to calculate total assigned hours.
|
|
||||||
|
|
||||||
const { employeeHash, assignmentHash } = CalculateExpectedHoursForJob(job);
|
const { employeeHash, assignmentHash } = CalculateExpectedHoursForJob(job);
|
||||||
const ticketHash = CalculateTicketsHoursForJob(job);
|
const ticketHash = CalculateTicketsHoursForJob(job);
|
||||||
|
|
||||||
if (assignmentHash.unassigned > 0) {
|
if (assignmentHash.unassigned > 0) {
|
||||||
res.json({ success: false, error: "Not all hours have been assigned." });
|
res.json({ success: false, error: "Not all hours have been assigned." });
|
||||||
return;
|
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 ticketsToInsert = [];
|
||||||
|
const employeeIds = getAllKeys(employeeHash, ticketHash);
|
||||||
|
|
||||||
recursiveDiff.forEach((diff) => {
|
employeeIds.forEach((employeeId) => {
|
||||||
//Every iteration is what we would need to insert into the time ticket hash
|
const expectedByLabor = employeeHash[employeeId] || {};
|
||||||
//so that it would match the employee hash exactly.
|
const claimedByLabor = ticketHash[employeeId] || {};
|
||||||
const path = diffParser(diff);
|
|
||||||
|
|
||||||
if (diff.op === "add") {
|
getAllKeys(expectedByLabor, claimedByLabor).forEach((laborType) => {
|
||||||
// console.log(Object.keys(diff.val));
|
const expected = expectedByLabor[laborType];
|
||||||
if (typeof diff.val === "object" && Object.keys(diff.val).length > 1) {
|
const claimed = claimedByLabor[laborType];
|
||||||
//Multiple values to add.
|
const deltaHours = roundHours((expected?.hours || 0) - (claimed?.hours || 0));
|
||||||
Object.keys(diff.val).forEach((key) => {
|
|
||||||
// console.log("Hours", diff.val[key][Object.keys(diff.val[key])[0]]);
|
if (deltaHours === 0) {
|
||||||
// console.log("Rate", Object.keys(diff.val[key])[0]);
|
return;
|
||||||
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})`
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else if (diff.op === "update") {
|
|
||||||
//An old ticket amount isn't sufficient
|
const effectiveRate = roundCurrency(expected?.rate ?? claimed?.rate);
|
||||||
//We can't modify the existing ticket, it might already be committed. So let's add a new one instead.
|
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({
|
ticketsToInsert.push({
|
||||||
task_name: "Pay All",
|
task_name: "Pay All",
|
||||||
jobid: job.id,
|
jobid: job.id,
|
||||||
bodyshopid: job.bodyshop.id,
|
bodyshopid: job.bodyshop.id,
|
||||||
employeeid: path.employeeid,
|
employeeid: employeeId,
|
||||||
productivehrs: diff.val - diff.oldVal,
|
productivehrs: deltaHours,
|
||||||
rate: path.rate,
|
rate: effectiveRate,
|
||||||
ciecacode: path.mod_lbr_ty,
|
ciecacode: laborType,
|
||||||
|
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborType],
|
||||||
flat_rate: true,
|
flat_rate: true,
|
||||||
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty],
|
created_by: req.user.email,
|
||||||
memo: `Adjust flagged hours per assignment. (${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, {
|
const filteredTickets = ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0);
|
||||||
timetickets: 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) {
|
} catch (error) {
|
||||||
logger.log("job-payroll-labor-totals-error", "ERROR", req.user.email, jobid, {
|
logger.log("job-payroll-labor-totals-error", "ERROR", req.user.email, jobid, {
|
||||||
jobid: jobid,
|
jobid,
|
||||||
error: JSON.stringify(error)
|
error: JSON.stringify(error)
|
||||||
});
|
});
|
||||||
res.status(400).json({ error: error.message });
|
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) {
|
function CalculateExpectedHoursForJob(job, filterToLbrTypes) {
|
||||||
const assignmentHash = { unassigned: 0 };
|
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
|
job.joblines
|
||||||
.filter((jobline) => {
|
.filter((jobline) => {
|
||||||
if (!filterToLbrTypes) return true;
|
if (!laborTypeFilter) {
|
||||||
else {
|
return true;
|
||||||
return (
|
|
||||||
filterToLbrTypes.includes(jobline.mod_lbr_ty) ||
|
|
||||||
(jobline.convertedtolbr && filterToLbrTypes.includes(jobline.convertedtolbr_data.mod_lbr_ty))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const convertedLaborType = jobline.convertedtolbr ? jobline.convertedtolbr_data?.mod_lbr_ty : null;
|
||||||
|
return laborTypeFilter.includes(jobline.mod_lbr_ty) || (convertedLaborType && laborTypeFilter.includes(convertedLaborType));
|
||||||
})
|
})
|
||||||
.forEach((jobline) => {
|
.forEach((jobline) => {
|
||||||
if (jobline.convertedtolbr) {
|
const laborType = jobline.convertedtolbr ? jobline.convertedtolbr_data?.mod_lbr_ty || jobline.mod_lbr_ty : jobline.mod_lbr_ty;
|
||||||
// Line has been converte to labor. Temporarily re-assign the hours.
|
const laborHours = roundHours(
|
||||||
jobline.mod_lbr_ty = jobline.convertedtolbr_data.mod_lbr_ty;
|
toNumber(jobline.mod_lb_hrs) + (jobline.convertedtolbr ? toNumber(jobline.convertedtolbr_data?.mod_lb_hrs) : 0)
|
||||||
jobline.mod_lb_hrs += jobline.convertedtolbr_data.mod_lb_hrs;
|
);
|
||||||
|
|
||||||
|
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.
|
if (jobline.assigned_team === null) {
|
||||||
const theTeam = job.bodyshop.employee_teams.find((team) => team.id === jobline.assigned_team);
|
assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
theTeam.employee_team_members.forEach((tm) => {
|
const theTeam = job.bodyshop.employee_teams.find((team) => team.id === jobline.assigned_team);
|
||||||
//Figure out how many hours they are owed at this line, and at what rate.
|
|
||||||
|
|
||||||
if (!employeeHash[tm.employee.id]) {
|
if (!theTeam) {
|
||||||
employeeHash[tm.employee.id] = {};
|
assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours);
|
||||||
}
|
return;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hoursOwed = (tm.percentage * jobline.mod_lb_hrs) / 100;
|
assignmentHash[jobline.assigned_team] = roundHours((assignmentHash[jobline.assigned_team] || 0) + laborHours);
|
||||||
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;
|
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 };
|
return { assignmentHash, employeeHash };
|
||||||
}
|
}
|
||||||
|
|
||||||
function CalculateTicketsHoursForJob(job) {
|
function CalculateTicketsHoursForJob(job) {
|
||||||
const ticketHash = {}; // employeeid => Cieca labor type => rate => hours.
|
const ticketHash = {}; // employeeid => Cieca labor type => { hours, rate, payoutContext }
|
||||||
//Calculate how much each employee has been paid so far.
|
|
||||||
job.timetickets.forEach((ticket) => {
|
job.timetickets.forEach((ticket) => {
|
||||||
|
if (!ticket?.employeeid || !ticket?.ciecacode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!ticketHash[ticket.employeeid]) {
|
if (!ticketHash[ticket.employeeid]) {
|
||||||
ticketHash[ticket.employeeid] = {};
|
ticketHash[ticket.employeeid] = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ticketHash[ticket.employeeid][ticket.ciecacode]) {
|
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;
|
return ticketHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exports.BuildPayoutDetails = BuildPayoutDetails;
|
||||||
exports.CalculateExpectedHoursForJob = CalculateExpectedHoursForJob;
|
exports.CalculateExpectedHoursForJob = CalculateExpectedHoursForJob;
|
||||||
exports.CalculateTicketsHoursForJob = CalculateTicketsHoursForJob;
|
exports.CalculateTicketsHoursForJob = CalculateTicketsHoursForJob;
|
||||||
|
exports.RoundPayrollHours = roundHours;
|
||||||
|
|||||||
465
server/payroll/payroll.test.js
Normal file
465
server/payroll/payroll.test.js
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
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.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a useful error when an hourly payout rate is missing", () => {
|
||||||
|
expect(() =>
|
||||||
|
payAllModule.BuildPayoutDetails(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
labor_rates: {},
|
||||||
|
employee: {
|
||||||
|
first_name: "John",
|
||||||
|
last_name: "Smith"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"LAB"
|
||||||
|
)
|
||||||
|
).toThrow("Missing hourly payout rate for John Smith on labor type LAB.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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%."
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects claim-task when an assigned team member is missing the hourly rate for the selected labor type", async () => {
|
||||||
|
const job = buildBaseJob({
|
||||||
|
bodyshop: {
|
||||||
|
id: "shop-1",
|
||||||
|
md_responsibility_centers: {
|
||||||
|
defaults: {
|
||||||
|
costs: {
|
||||||
|
LAB: "Body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
md_tasks_presets: {
|
||||||
|
presets: [
|
||||||
|
{
|
||||||
|
name: "Teardown",
|
||||||
|
hourstype: ["LAB"],
|
||||||
|
percent: 100,
|
||||||
|
nextstatus: "In Progress",
|
||||||
|
memo: "Teardown"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
employee_teams: [
|
||||||
|
{
|
||||||
|
id: "team-1",
|
||||||
|
employee_team_members: [
|
||||||
|
{
|
||||||
|
percentage: 50,
|
||||||
|
labor_rates: {
|
||||||
|
LAB: 45
|
||||||
|
},
|
||||||
|
employee: {
|
||||||
|
id: "emp-1",
|
||||||
|
first_name: "Configured",
|
||||||
|
last_name: "Tech"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
percentage: 50,
|
||||||
|
labor_rates: {},
|
||||||
|
employee: {
|
||||||
|
id: "emp-2",
|
||||||
|
first_name: "Missing",
|
||||||
|
last_name: "Rate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
joblines: [
|
||||||
|
{
|
||||||
|
mod_lbr_ty: "LAB",
|
||||||
|
mod_lb_hrs: 4.4,
|
||||||
|
assigned_team: "team-1",
|
||||||
|
convertedtolbr: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const { client, req, res } = buildReqRes({
|
||||||
|
job,
|
||||||
|
body: {
|
||||||
|
task: "Teardown",
|
||||||
|
calculateOnly: true,
|
||||||
|
employee: {
|
||||||
|
name: "Dave",
|
||||||
|
email: "dave@rome.test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await claimTaskModule.claimtask(req, res);
|
||||||
|
|
||||||
|
expect(client.request).toHaveBeenCalledTimes(1);
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
success: false,
|
||||||
|
error: "Missing hourly payout rate for Missing Rate on labor type LAB."
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
|
|||||||
const { RRClient } = require("./lib/index.cjs");
|
const { RRClient } = require("./lib/index.cjs");
|
||||||
const { getRRConfigFromBodyshop } = require("./rr-config");
|
const { getRRConfigFromBodyshop } = require("./rr-config");
|
||||||
const CreateRRLogEvent = require("./rr-logger-event");
|
const CreateRRLogEvent = require("./rr-logger-event");
|
||||||
|
const { withRRRequestXml } = require("./rr-log-xml");
|
||||||
const InstanceManager = require("../utils/instanceMgr").default;
|
const InstanceManager = require("../utils/instanceMgr").default;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -217,14 +218,24 @@ const createRRCustomer = async ({ bodyshop, job, overrides = {}, socket }) => {
|
|||||||
try {
|
try {
|
||||||
response = await client.insertCustomer(safePayload, opts);
|
response = await client.insertCustomer(safePayload, opts);
|
||||||
// Very noisy; only show when log level is cranked to SILLY
|
// 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) {
|
} catch (e) {
|
||||||
CreateRRLogEvent(socket, "ERROR", "RR insertCustomer transport error", {
|
CreateRRLogEvent(
|
||||||
message: e?.message,
|
socket,
|
||||||
code: e?.code,
|
"ERROR",
|
||||||
status: e?.meta?.status || e?.status,
|
"RR insertCustomer transport error",
|
||||||
payload: safePayload
|
withRRRequestXml(e, {
|
||||||
});
|
message: e?.message,
|
||||||
|
code: e?.code,
|
||||||
|
status: e?.meta?.status || e?.status,
|
||||||
|
payload: safePayload
|
||||||
|
})
|
||||||
|
);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,12 +244,17 @@ const createRRCustomer = async ({ bodyshop, job, overrides = {}, socket }) => {
|
|||||||
|
|
||||||
let customerNo = data?.dmsRecKey;
|
let customerNo = data?.dmsRecKey;
|
||||||
if (!customerNo) {
|
if (!customerNo) {
|
||||||
CreateRRLogEvent(socket, "ERROR", "RR insertCustomer returned no dmsRecKey/custNo", {
|
CreateRRLogEvent(
|
||||||
status: trx?.status,
|
socket,
|
||||||
statusCode: trx?.statusCode,
|
"ERROR",
|
||||||
message: trx?.message,
|
"RR insertCustomer returned no dmsRecKey/custNo",
|
||||||
data
|
withRRRequestXml(response, {
|
||||||
});
|
status: trx?.status,
|
||||||
|
statusCode: trx?.statusCode,
|
||||||
|
message: trx?.message,
|
||||||
|
data
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`RR insertCustomer returned no dmsRecKey (status=${trx?.status ?? "?"} code=${trx?.statusCode ?? "?"}${
|
`RR insertCustomer returned no dmsRecKey (status=${trx?.status ?? "?"} code=${trx?.statusCode ?? "?"}${
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* @returns {number|null}
|
* @returns {number|null}
|
||||||
*/
|
*/
|
||||||
const parseVendorStatusCode = (err) => {
|
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 codeProp = err?.code ?? err?.statusCode ?? err?.meta?.status?.StatusCode ?? err?.status?.StatusCode;
|
||||||
const num = Number(codeProp);
|
const num = Number(codeProp);
|
||||||
if (!Number.isNaN(num) && num > 0) return num;
|
if (!Number.isNaN(num) && num > 0) return num;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const { GraphQLClient } = require("graphql-request");
|
const { GraphQLClient } = require("graphql-request");
|
||||||
const queries = require("../graphql-client/queries");
|
const queries = require("../graphql-client/queries");
|
||||||
const CreateRRLogEvent = require("./rr-logger-event");
|
const CreateRRLogEvent = require("./rr-logger-event");
|
||||||
|
const { extractRRXmlPair } = require("./rr-log-xml");
|
||||||
|
|
||||||
/** Get bearer token from the socket (same approach used elsewhere) */
|
/** Get bearer token from the socket (same approach used elsewhere) */
|
||||||
const getAuthToken = (socket) =>
|
const getAuthToken = (socket) =>
|
||||||
@@ -178,11 +179,23 @@ const insertRRFailedExportLog = async ({ socket, jobId, job, bodyshop, error, cl
|
|||||||
const client = new GraphQLClient(endpoint, {});
|
const client = new GraphQLClient(endpoint, {});
|
||||||
client.setHeaders({ Authorization: `Bearer ${token}` });
|
client.setHeaders({ Authorization: `Bearer ${token}` });
|
||||||
|
|
||||||
|
const { requestXml, responseXml } = extractRRXmlPair(error);
|
||||||
|
const xmlFromError =
|
||||||
|
requestXml || responseXml
|
||||||
|
? {
|
||||||
|
...(requestXml ? { request: requestXml } : {}),
|
||||||
|
...(responseXml ? { response: responseXml } : {})
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const meta = buildRRExportMeta({
|
const meta = buildRRExportMeta({
|
||||||
result,
|
result,
|
||||||
extra: {
|
extra: {
|
||||||
error: error?.message || String(error),
|
error: error?.message || String(error),
|
||||||
classification: classification || undefined
|
classification: classification || undefined,
|
||||||
|
...(requestXml ? { requestXml } : {}),
|
||||||
|
...(responseXml ? { responseXml } : {}),
|
||||||
|
...(xmlFromError && !result?.xml ? { xml: xmlFromError } : {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const { buildRRRepairOrderPayload } = require("./rr-job-helpers");
|
const { buildRRRepairOrderPayload } = require("./rr-job-helpers");
|
||||||
const { buildClientAndOpts } = require("./rr-lookup");
|
const { buildClientAndOpts } = require("./rr-lookup");
|
||||||
const CreateRRLogEvent = require("./rr-logger-event");
|
const CreateRRLogEvent = require("./rr-logger-event");
|
||||||
|
const { withRRRequestXml } = require("./rr-log-xml");
|
||||||
const { extractRrResponsibilityCenters } = require("./rr-responsibility-centers");
|
const { extractRrResponsibilityCenters } = require("./rr-responsibility-centers");
|
||||||
const CdkCalculateAllocations = require("./rr-calculate-allocations").default;
|
const CdkCalculateAllocations = require("./rr-calculate-allocations").default;
|
||||||
const { resolveRROpCodeFromBodyshop } = require("./rr-utils");
|
const { resolveRROpCodeFromBodyshop } = require("./rr-utils");
|
||||||
@@ -147,10 +148,7 @@ const createMinimalRRRepairOrder = async (args) => {
|
|||||||
|
|
||||||
const response = await client.createRepairOrder(payload, finalOpts);
|
const response = await client.createRepairOrder(payload, finalOpts);
|
||||||
|
|
||||||
CreateRRLogEvent(socket, "INFO", "RR minimal Repair Order created", {
|
CreateRRLogEvent(socket, "INFO", "RR minimal Repair Order created", withRRRequestXml(response, { payload, response }));
|
||||||
payload,
|
|
||||||
response
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = response?.data || null;
|
const data = response?.data || null;
|
||||||
const statusBlocks = response?.statusBlocks || {};
|
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
|
// 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
|
// 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),
|
roNo: String(roNo),
|
||||||
hasRolabor: !!payload.rolabor,
|
hasRolabor: !!payload.rolabor,
|
||||||
hasRogg: !!payload.rogg,
|
hasRogg: !!payload.rogg,
|
||||||
@@ -338,10 +336,18 @@ const updateRRRepairOrderWithFullData = async (args) => {
|
|||||||
// Reynolds will merge this with the existing RO header
|
// Reynolds will merge this with the existing RO header
|
||||||
const response = await client.createRepairOrder(payload, finalOpts);
|
const response = await client.createRepairOrder(payload, finalOpts);
|
||||||
|
|
||||||
CreateRRLogEvent(socket, "INFO", "RR Repair Order full data sent", {
|
CreateRRLogEvent(
|
||||||
payload,
|
socket,
|
||||||
response
|
"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 data = response?.data || null;
|
||||||
const statusBlocks = response?.statusBlocks || {};
|
const statusBlocks = response?.statusBlocks || {};
|
||||||
@@ -501,10 +507,7 @@ const exportJobToRR = async (args) => {
|
|||||||
|
|
||||||
const response = await client.createRepairOrder(payload, finalOpts);
|
const response = await client.createRepairOrder(payload, finalOpts);
|
||||||
|
|
||||||
CreateRRLogEvent(socket, "INFO", "RR raw Repair Order created", {
|
CreateRRLogEvent(socket, "INFO", "RR raw Repair Order created", withRRRequestXml(response, { payload, response }));
|
||||||
payload,
|
|
||||||
response
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = response?.data || null;
|
const data = response?.data || null;
|
||||||
const statusBlocks = response?.statusBlocks || {};
|
const statusBlocks = response?.statusBlocks || {};
|
||||||
@@ -603,10 +606,15 @@ const finalizeRRRepairOrder = async (args) => {
|
|||||||
|
|
||||||
const rrRes = await client.updateRepairOrder(payload, finalOpts);
|
const rrRes = await client.updateRepairOrder(payload, finalOpts);
|
||||||
|
|
||||||
CreateRRLogEvent(socket, "SILLY", "RR Repair Order finalized", {
|
CreateRRLogEvent(
|
||||||
payload,
|
socket,
|
||||||
response: rrRes
|
"SILLY",
|
||||||
});
|
"RR Repair Order finalized",
|
||||||
|
withRRRequestXml(rrRes, {
|
||||||
|
payload,
|
||||||
|
response: rrRes
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const data = rrRes?.data || null;
|
const data = rrRes?.data || null;
|
||||||
const statusBlocks = rrRes?.statusBlocks || {};
|
const statusBlocks = rrRes?.statusBlocks || {};
|
||||||
|
|||||||
63
server/rr/rr-log-xml.js
Normal file
63
server/rr/rr-log-xml.js
Normal 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
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ const { createRRCustomer } = require("./rr-customers");
|
|||||||
const { ensureRRServiceVehicle } = require("./rr-service-vehicles");
|
const { ensureRRServiceVehicle } = require("./rr-service-vehicles");
|
||||||
const { classifyRRVendorError } = require("./rr-errors");
|
const { classifyRRVendorError } = require("./rr-errors");
|
||||||
const { markRRExportSuccess, insertRRFailedExportLog } = require("./rr-export-logs");
|
const { markRRExportSuccess, insertRRFailedExportLog } = require("./rr-export-logs");
|
||||||
|
const { withRRRequestXml, extractRRXmlPair } = require("./rr-log-xml");
|
||||||
const {
|
const {
|
||||||
makeVehicleSearchPayloadFromJob,
|
makeVehicleSearchPayloadFromJob,
|
||||||
ownersFromVinBlocks,
|
ownersFromVinBlocks,
|
||||||
@@ -48,6 +49,21 @@ const resolveJobId = (explicit, payload, job) => explicit || payload?.jobId || j
|
|||||||
*/
|
*/
|
||||||
const resolveVin = ({ tx, job }) => tx?.jobData?.vin || job?.v_vin || null;
|
const resolveVin = ({ tx, job }) => tx?.jobData?.vin || job?.v_vin || null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add request/response XML to socket event payloads when available.
|
||||||
|
* @param rrObj
|
||||||
|
* @param payload
|
||||||
|
* @returns {*}
|
||||||
|
*/
|
||||||
|
const withRRXmlSocketPayload = (rrObj, payload = {}) => {
|
||||||
|
const { requestXml, responseXml } = extractRRXmlPair(rrObj);
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
...(requestXml ? { requestXml } : {}),
|
||||||
|
...(responseXml ? { responseXml } : {})
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sort vehicle owners first in the list, preserving original order otherwise.
|
* Sort vehicle owners first in the list, preserving original order otherwise.
|
||||||
* @param list
|
* @param list
|
||||||
@@ -154,15 +170,13 @@ const setJobDmsIdForSocket = async ({ socket, jobId, dmsId, dmsCustomerId, dmsAd
|
|||||||
if (!token) throw new Error("Missing auth token for setJobDmsIdForSocket");
|
if (!token) throw new Error("Missing auth token for setJobDmsIdForSocket");
|
||||||
|
|
||||||
const client = new GraphQLClient(endpoint, {});
|
const client = new GraphQLClient(endpoint, {});
|
||||||
await client
|
await client.setHeaders({ Authorization: `Bearer ${token}` }).request(queries.SET_JOB_DMS_ID, {
|
||||||
.setHeaders({ Authorization: `Bearer ${token}` })
|
id: jobId,
|
||||||
.request(queries.SET_JOB_DMS_ID, {
|
dms_id: String(dmsId),
|
||||||
id: jobId,
|
dms_customer_id: dmsCustomerId ? String(dmsCustomerId) : null,
|
||||||
dms_id: String(dmsId),
|
dms_advisor_id: dmsAdvisorId ? String(dmsAdvisorId) : null,
|
||||||
dms_customer_id: dmsCustomerId ? String(dmsCustomerId) : null,
|
kmin: mileageIn != null && mileageIn > 0 ? parseInt(mileageIn, 10) : 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", {
|
CreateRRLogEvent(socket, "INFO", "Linked job.dms_id to RR RO", {
|
||||||
jobId,
|
jobId,
|
||||||
@@ -241,7 +255,12 @@ const rrMultiCustomerSearch = async ({ bodyshop, job, socket, redisHelpers }) =>
|
|||||||
|
|
||||||
const multiResponse = await rrCombinedSearch(bodyshop, q);
|
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) {
|
if (fromVin) {
|
||||||
const multiBlocks = Array.isArray(multiResponse?.data) ? multiResponse.data : [];
|
const multiBlocks = Array.isArray(multiResponse?.data) ? multiResponse.data : [];
|
||||||
@@ -262,7 +281,7 @@ const rrMultiCustomerSearch = async ({ bodyshop, job, socket, redisHelpers }) =>
|
|||||||
const norm = normalizeCustomerCandidates(multiResponse, { ownersSet });
|
const norm = normalizeCustomerCandidates(multiResponse, { ownersSet });
|
||||||
merged.push(...norm);
|
merged.push(...norm);
|
||||||
} catch (e) {
|
} 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 }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +329,7 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
count: decorated.length
|
count: decorated.length
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} 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 });
|
cb?.({ jobid, error: e.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -387,7 +406,7 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
fromCache
|
fromCache
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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" });
|
ack?.({ ok: false, error: err?.message || "get advisors failed" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -458,14 +477,26 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
anyOwner: decorated.some((c) => c.vinOwner || c.isVehicleOwner)
|
anyOwner: decorated.some((c) => c.vinOwner || c.isVehicleOwner)
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
CreateRRLogEvent(socket, "ERROR", `Error during RR early RO creation (prepare)`, {
|
CreateRRLogEvent(
|
||||||
error: error.message,
|
socket,
|
||||||
stack: error.stack,
|
"ERROR",
|
||||||
jobid: rid
|
`Error during RR early RO creation (prepare)`,
|
||||||
});
|
withRRRequestXml(error, {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
jobid: rid
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message });
|
socket.emit(
|
||||||
|
"export-failed",
|
||||||
|
withRRXmlSocketPayload(error, {
|
||||||
|
vendor: "rr",
|
||||||
|
jobId: rid,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
//
|
//
|
||||||
}
|
}
|
||||||
@@ -511,7 +542,11 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Filter out invalid values
|
// Filter out invalid values
|
||||||
if (selectedCustNo === "undefined" || selectedCustNo === "null" || (selectedCustNo && selectedCustNo.trim() === "")) {
|
if (
|
||||||
|
selectedCustNo === "undefined" ||
|
||||||
|
selectedCustNo === "null" ||
|
||||||
|
(selectedCustNo && selectedCustNo.trim() === "")
|
||||||
|
) {
|
||||||
selectedCustNo = null;
|
selectedCustNo = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,7 +590,12 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
if (vehQ && vehQ.kind === "vin" && job?.v_vin) {
|
if (vehQ && vehQ.kind === "vin" && job?.v_vin) {
|
||||||
const vinResponse = await rrCombinedSearch(bodyshop, vehQ);
|
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 : [];
|
const vinBlocks = Array.isArray(vinResponse?.data) ? vinResponse.data : [];
|
||||||
|
|
||||||
@@ -588,9 +628,14 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
CreateRRLogEvent(socket, "WARN", `VIN owner pre-check failed; continuing with selected customer (early RO)`, {
|
CreateRRLogEvent(
|
||||||
error: e?.message
|
socket,
|
||||||
});
|
"WARN",
|
||||||
|
`VIN owner pre-check failed; continuing with selected customer (early RO)`,
|
||||||
|
withRRRequestXml(e, {
|
||||||
|
error: e?.message
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache final/effective customer selection
|
// Cache final/effective customer selection
|
||||||
@@ -705,42 +750,52 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
|
|
||||||
const outsdRoNo = data?.outsdRoNo ?? job?.ro_number ?? job?.id ?? null;
|
const outsdRoNo = data?.outsdRoNo ?? job?.ro_number ?? job?.id ?? null;
|
||||||
|
|
||||||
CreateRRLogEvent(socket, "DEBUG", "Early RO created - checking dmsRoNo", {
|
CreateRRLogEvent(
|
||||||
dmsRoNo,
|
socket,
|
||||||
resultRoNo: result?.roNo,
|
"DEBUG",
|
||||||
dataRoNo: data?.dmsRoNo,
|
"Early RO created - checking dmsRoNo",
|
||||||
jobId: rid
|
withRRRequestXml(result, {
|
||||||
});
|
dmsRoNo,
|
||||||
|
resultRoNo: result?.roNo,
|
||||||
|
dataRoNo: data?.dmsRoNo,
|
||||||
|
jobId: rid
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// ✅ Persist DMS RO number, customer ID, advisor ID, and mileage on the job
|
// ✅ Persist DMS RO number, customer ID, advisor ID, and mileage on the job
|
||||||
if (dmsRoNo) {
|
if (dmsRoNo) {
|
||||||
const mileageIn = txEnvelope?.kmin ?? null;
|
const mileageIn = txEnvelope?.kmin ?? null;
|
||||||
CreateRRLogEvent(socket, "DEBUG", "Calling setJobDmsIdForSocket", {
|
CreateRRLogEvent(socket, "DEBUG", "Calling setJobDmsIdForSocket", {
|
||||||
jobId: rid,
|
jobId: rid,
|
||||||
dmsId: dmsRoNo,
|
dmsId: dmsRoNo,
|
||||||
customerId: effectiveCustNo,
|
customerId: effectiveCustNo,
|
||||||
advisorId: String(advisorNo),
|
advisorId: String(advisorNo),
|
||||||
mileageIn
|
mileageIn
|
||||||
});
|
});
|
||||||
await setJobDmsIdForSocket({
|
await setJobDmsIdForSocket({
|
||||||
socket,
|
socket,
|
||||||
jobId: rid,
|
jobId: rid,
|
||||||
dmsId: dmsRoNo,
|
dmsId: dmsRoNo,
|
||||||
dmsCustomerId: effectiveCustNo,
|
dmsCustomerId: effectiveCustNo,
|
||||||
dmsAdvisorId: String(advisorNo),
|
dmsAdvisorId: String(advisorNo),
|
||||||
mileageIn
|
mileageIn
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
CreateRRLogEvent(socket, "WARN", "RR early RO creation succeeded but no DMS RO number was returned", {
|
CreateRRLogEvent(
|
||||||
jobId: rid,
|
socket,
|
||||||
resultPreview: {
|
"WARN",
|
||||||
roNo: result?.roNo,
|
"RR early RO creation succeeded but no DMS RO number was returned",
|
||||||
data: {
|
withRRRequestXml(result, {
|
||||||
dmsRoNo: data?.dmsRoNo,
|
jobId: rid,
|
||||||
outsdRoNo: data?.outsdRoNo
|
resultPreview: {
|
||||||
|
roNo: result?.roNo,
|
||||||
|
data: {
|
||||||
|
dmsRoNo: data?.dmsRoNo,
|
||||||
|
outsdRoNo: data?.outsdRoNo
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await redisHelpers.setSessionTransactionData(
|
await redisHelpers.setSessionTransactionData(
|
||||||
@@ -758,10 +813,15 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
defaultRRTTL
|
defaultRRTTL
|
||||||
);
|
);
|
||||||
|
|
||||||
CreateRRLogEvent(socket, "INFO", `{EARLY-5} Minimal RO created successfully`, {
|
CreateRRLogEvent(
|
||||||
dmsRoNo: dmsRoNo || null,
|
socket,
|
||||||
outsdRoNo: outsdRoNo || null
|
"INFO",
|
||||||
});
|
`{EARLY-5} Minimal RO created successfully`,
|
||||||
|
withRRRequestXml(result, {
|
||||||
|
dmsRoNo: dmsRoNo || null,
|
||||||
|
outsdRoNo: outsdRoNo || null
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Mark success in export logs
|
// Mark success in export logs
|
||||||
await markRRExportSuccess({
|
await markRRExportSuccess({
|
||||||
@@ -810,11 +870,16 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
message: vendorMessage
|
message: vendorMessage
|
||||||
});
|
});
|
||||||
|
|
||||||
CreateRRLogEvent(socket, "ERROR", `Early RO creation failed`, {
|
CreateRRLogEvent(
|
||||||
roStatus: result?.roStatus,
|
socket,
|
||||||
statusBlocks: result?.statusBlocks,
|
"ERROR",
|
||||||
classification: cls
|
`Early RO creation failed`,
|
||||||
});
|
withRRRequestXml(result, {
|
||||||
|
roStatus: result?.roStatus,
|
||||||
|
statusBlocks: result?.statusBlocks,
|
||||||
|
classification: cls
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
await insertRRFailedExportLog({
|
await insertRRFailedExportLog({
|
||||||
socket,
|
socket,
|
||||||
@@ -827,9 +892,11 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.emit("export-failed", {
|
socket.emit("export-failed", {
|
||||||
vendor: "rr",
|
...withRRXmlSocketPayload(result, {
|
||||||
jobId: rid,
|
vendor: "rr",
|
||||||
error: cls?.friendlyMessage || result?.error || "RR early RO creation failed",
|
jobId: rid,
|
||||||
|
error: cls?.friendlyMessage || result?.error || "RR early RO creation failed"
|
||||||
|
}),
|
||||||
...cls
|
...cls
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -843,14 +910,19 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const cls = classifyRRVendorError(error);
|
const cls = classifyRRVendorError(error);
|
||||||
|
|
||||||
CreateRRLogEvent(socket, "ERROR", `Error during RR early RO creation (customer-selected)`, {
|
CreateRRLogEvent(
|
||||||
error: error.message,
|
socket,
|
||||||
vendorStatusCode: cls.vendorStatusCode,
|
"ERROR",
|
||||||
code: cls.errorCode,
|
`Error during RR early RO creation (customer-selected)`,
|
||||||
friendly: cls.friendlyMessage,
|
withRRRequestXml(error, {
|
||||||
stack: error.stack,
|
error: error.message,
|
||||||
jobid: rid
|
vendorStatusCode: cls.vendorStatusCode,
|
||||||
});
|
code: cls.errorCode,
|
||||||
|
friendly: cls.friendlyMessage,
|
||||||
|
stack: error.stack,
|
||||||
|
jobid: rid
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!bodyshop || !job) {
|
if (!bodyshop || !job) {
|
||||||
@@ -875,9 +947,11 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
socket.emit("export-failed", {
|
socket.emit("export-failed", {
|
||||||
vendor: "rr",
|
...withRRXmlSocketPayload(error, {
|
||||||
jobId: rid,
|
vendor: "rr",
|
||||||
error: error.message,
|
jobId: rid,
|
||||||
|
error: error.message
|
||||||
|
}),
|
||||||
...cls
|
...cls
|
||||||
});
|
});
|
||||||
socket.emit("rr-user-notice", { jobId: rid, ...cls });
|
socket.emit("rr-user-notice", { jobId: rid, ...cls });
|
||||||
@@ -940,14 +1014,14 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
|
|
||||||
// Check if this job already has an early RO - if so, use stored IDs and skip customer search
|
// Check if this job already has an early RO - if so, use stored IDs and skip customer search
|
||||||
const hasEarlyRO = !!job?.dms_id;
|
const hasEarlyRO = !!job?.dms_id;
|
||||||
|
|
||||||
if (hasEarlyRO) {
|
if (hasEarlyRO) {
|
||||||
CreateRRLogEvent(socket, "DEBUG", `{2} Early RO exists - using stored customer/advisor`, {
|
CreateRRLogEvent(socket, "DEBUG", `{2} Early RO exists - using stored customer/advisor`, {
|
||||||
dms_id: job.dms_id,
|
dms_id: job.dms_id,
|
||||||
dms_customer_id: job.dms_customer_id,
|
dms_customer_id: job.dms_customer_id,
|
||||||
dms_advisor_id: job.dms_advisor_id
|
dms_advisor_id: job.dms_advisor_id
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cache the stored customer/advisor IDs for the next step
|
// Cache the stored customer/advisor IDs for the next step
|
||||||
if (job.dms_customer_id) {
|
if (job.dms_customer_id) {
|
||||||
await redisHelpers.setSessionTransactionData(
|
await redisHelpers.setSessionTransactionData(
|
||||||
@@ -967,18 +1041,18 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
defaultRRTTL
|
defaultRRTTL
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit empty customer list to frontend (won't show modal)
|
// Emit empty customer list to frontend (won't show modal)
|
||||||
socket.emit("rr-select-customer", []);
|
socket.emit("rr-select-customer", []);
|
||||||
|
|
||||||
// Continue directly with the export by calling the selected customer handler logic inline
|
// 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
|
// This is essentially the same as if user selected the stored customer
|
||||||
const selectedCustNo = job.dms_customer_id;
|
const selectedCustNo = job.dms_customer_id;
|
||||||
|
|
||||||
if (!selectedCustNo) {
|
if (!selectedCustNo) {
|
||||||
throw new Error("Early RO exists but no customer ID stored");
|
throw new Error("Early RO exists but no customer ID stored");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue with ensureRRServiceVehicle and export (same as rr-selected-customer handler)
|
// Continue with ensureRRServiceVehicle and export (same as rr-selected-customer handler)
|
||||||
const { client, opts } = await buildClientAndOpts(bodyshop);
|
const { client, opts } = await buildClientAndOpts(bodyshop);
|
||||||
const routing = opts?.routing || client?.opts?.routing || null;
|
const routing = opts?.routing || client?.opts?.routing || null;
|
||||||
@@ -1011,7 +1085,12 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
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) {
|
if (!advisorNo) {
|
||||||
throw new Error("Advisor is required (advisorNo).");
|
throw new Error("Advisor is required (advisorNo).");
|
||||||
@@ -1030,7 +1109,28 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
roNo: job.dms_id
|
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) {
|
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");
|
throw new Error(result?.roStatus?.message || "Failed to update RR Repair Order");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1059,15 +1159,20 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
defaultRRTTL
|
defaultRRTTL
|
||||||
);
|
);
|
||||||
|
|
||||||
CreateRRLogEvent(socket, "INFO", `RR Repair Order updated successfully`, {
|
CreateRRLogEvent(
|
||||||
dmsRoNo,
|
socket,
|
||||||
jobId: rid
|
"INFO",
|
||||||
});
|
`RR Repair Order updated successfully`,
|
||||||
|
withRRRequestXml(result, {
|
||||||
|
dmsRoNo,
|
||||||
|
jobId: rid
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// For early RO flow, only emit validation-required (not export-job:result)
|
// 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
|
// since the export is not complete yet - we're just waiting for validation
|
||||||
socket.emit("rr-validation-required", { dmsRoNo, jobId: rid });
|
socket.emit("rr-validation-required", { dmsRoNo, jobId: rid });
|
||||||
|
|
||||||
return ack?.({ ok: true, skipCustomerSelection: true, dmsRoNo });
|
return ack?.({ ok: true, skipCustomerSelection: true, dmsRoNo });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1082,14 +1187,26 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
anyOwner: decorated.some((c) => c.vinOwner || c.isVehicleOwner)
|
anyOwner: decorated.some((c) => c.vinOwner || c.isVehicleOwner)
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
CreateRRLogEvent(socket, "ERROR", `Error during RR export (prepare)`, {
|
CreateRRLogEvent(
|
||||||
error: error.message,
|
socket,
|
||||||
stack: error.stack,
|
"ERROR",
|
||||||
jobid: rid
|
`Error during RR export (prepare)`,
|
||||||
});
|
withRRRequestXml(error, {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
jobid: rid
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message });
|
socket.emit(
|
||||||
|
"export-failed",
|
||||||
|
withRRXmlSocketPayload(error, {
|
||||||
|
vendor: "rr",
|
||||||
|
jobId: rid,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
//
|
//
|
||||||
}
|
}
|
||||||
@@ -1148,7 +1265,12 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
if (vehQ && vehQ.kind === "vin" && job?.v_vin) {
|
if (vehQ && vehQ.kind === "vin" && job?.v_vin) {
|
||||||
const vinResponse = await rrCombinedSearch(bodyshop, vehQ);
|
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 : [];
|
const vinBlocks = Array.isArray(vinResponse?.data) ? vinResponse.data : [];
|
||||||
|
|
||||||
@@ -1181,9 +1303,14 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
CreateRRLogEvent(socket, "WARN", `VIN owner pre-check failed; continuing with selected customer`, {
|
CreateRRLogEvent(
|
||||||
error: e?.message
|
socket,
|
||||||
});
|
"WARN",
|
||||||
|
`VIN owner pre-check failed; continuing with selected customer`,
|
||||||
|
withRRRequestXml(e, {
|
||||||
|
error: e?.message
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache final/effective customer selection
|
// Cache final/effective customer selection
|
||||||
@@ -1277,25 +1404,25 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
// When updating an early RO, use stored customer/advisor IDs
|
// When updating an early RO, use stored customer/advisor IDs
|
||||||
let finalEffectiveCustNo = effectiveCustNo;
|
let finalEffectiveCustNo = effectiveCustNo;
|
||||||
let finalAdvisorNo = advisorNo;
|
let finalAdvisorNo = advisorNo;
|
||||||
|
|
||||||
if (shouldUpdate && job?.dms_customer_id) {
|
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,
|
storedCustomerId: job.dms_customer_id,
|
||||||
originalCustomerId: effectiveCustNo
|
originalCustomerId: effectiveCustNo
|
||||||
});
|
});
|
||||||
finalEffectiveCustNo = String(job.dms_customer_id);
|
finalEffectiveCustNo = String(job.dms_customer_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldUpdate && job?.dms_advisor_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,
|
storedAdvisorId: job.dms_advisor_id,
|
||||||
originalAdvisorId: advisorNo
|
originalAdvisorId: advisorNo
|
||||||
});
|
});
|
||||||
finalAdvisorNo = String(job.dms_advisor_id);
|
finalAdvisorNo = String(job.dms_advisor_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
|
|
||||||
if (shouldUpdate) {
|
if (shouldUpdate) {
|
||||||
// UPDATE existing RO with full data
|
// UPDATE existing RO with full data
|
||||||
CreateRRLogEvent(socket, "DEBUG", `{4} Updating existing RR RO with full data`, { dmsRoNo: existingDmsId });
|
CreateRRLogEvent(socket, "DEBUG", `{4} Updating existing RR RO with full data`, { dmsRoNo: existingDmsId });
|
||||||
@@ -1344,16 +1471,21 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
if (dmsRoNo) {
|
if (dmsRoNo) {
|
||||||
await setJobDmsIdForSocket({ socket, jobId: rid, dmsId: dmsRoNo });
|
await setJobDmsIdForSocket({ socket, jobId: rid, dmsId: dmsRoNo });
|
||||||
} else {
|
} else {
|
||||||
CreateRRLogEvent(socket, "WARN", "RR export succeeded but no DMS RO number was returned", {
|
CreateRRLogEvent(
|
||||||
jobId: rid,
|
socket,
|
||||||
resultPreview: {
|
"WARN",
|
||||||
roNo: result?.roNo,
|
"RR export succeeded but no DMS RO number was returned",
|
||||||
data: {
|
withRRRequestXml(result, {
|
||||||
dmsRoNo: data?.dmsRoNo,
|
jobId: rid,
|
||||||
outsdRoNo: data?.outsdRoNo
|
resultPreview: {
|
||||||
|
roNo: result?.roNo,
|
||||||
|
data: {
|
||||||
|
dmsRoNo: data?.dmsRoNo,
|
||||||
|
outsdRoNo: data?.outsdRoNo
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await redisHelpers.setSessionTransactionData(
|
await redisHelpers.setSessionTransactionData(
|
||||||
@@ -1370,10 +1502,15 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
defaultRRTTL
|
defaultRRTTL
|
||||||
);
|
);
|
||||||
|
|
||||||
CreateRRLogEvent(socket, "INFO", `{5} RO created. Waiting for validation.`, {
|
CreateRRLogEvent(
|
||||||
dmsRoNo: dmsRoNo || null,
|
socket,
|
||||||
outsdRoNo: outsdRoNo || null
|
"INFO",
|
||||||
});
|
`{5} RO created. Waiting for validation.`,
|
||||||
|
withRRRequestXml(result, {
|
||||||
|
dmsRoNo: dmsRoNo || null,
|
||||||
|
outsdRoNo: outsdRoNo || null
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Tell FE to prompt for "Finished/Close"
|
// Tell FE to prompt for "Finished/Close"
|
||||||
socket.emit("rr-validation-required", { jobId: rid, dmsRoNo, outsdRoNo });
|
socket.emit("rr-validation-required", { jobId: rid, dmsRoNo, outsdRoNo });
|
||||||
@@ -1412,11 +1549,16 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
message: vendorMessage
|
message: vendorMessage
|
||||||
});
|
});
|
||||||
|
|
||||||
CreateRRLogEvent(socket, "ERROR", `Export failed (step 1)`, {
|
CreateRRLogEvent(
|
||||||
roStatus: result?.roStatus,
|
socket,
|
||||||
statusBlocks: result?.statusBlocks,
|
"ERROR",
|
||||||
classification: cls
|
`Export failed (step 1)`,
|
||||||
});
|
withRRRequestXml(result, {
|
||||||
|
roStatus: result?.roStatus,
|
||||||
|
statusBlocks: result?.statusBlocks,
|
||||||
|
classification: cls
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
await insertRRFailedExportLog({
|
await insertRRFailedExportLog({
|
||||||
socket,
|
socket,
|
||||||
@@ -1429,9 +1571,11 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.emit("export-failed", {
|
socket.emit("export-failed", {
|
||||||
vendor: "rr",
|
...withRRXmlSocketPayload(result, {
|
||||||
jobId: rid,
|
vendor: "rr",
|
||||||
error: cls?.friendlyMessage || result?.error || "RR export failed",
|
jobId: rid,
|
||||||
|
error: cls?.friendlyMessage || result?.error || "RR export failed"
|
||||||
|
}),
|
||||||
...cls
|
...cls
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1445,14 +1589,19 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const cls = classifyRRVendorError(error);
|
const cls = classifyRRVendorError(error);
|
||||||
|
|
||||||
CreateRRLogEvent(socket, "ERROR", `Error during RR export (selected-customer)`, {
|
CreateRRLogEvent(
|
||||||
error: error.message,
|
socket,
|
||||||
vendorStatusCode: cls.vendorStatusCode,
|
"ERROR",
|
||||||
code: cls.errorCode,
|
`Error during RR export (selected-customer)`,
|
||||||
friendly: cls.friendlyMessage,
|
withRRRequestXml(error, {
|
||||||
stack: error.stack,
|
error: error.message,
|
||||||
jobid: rid
|
vendorStatusCode: cls.vendorStatusCode,
|
||||||
});
|
code: cls.errorCode,
|
||||||
|
friendly: cls.friendlyMessage,
|
||||||
|
stack: error.stack,
|
||||||
|
jobid: rid
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!bodyshop || !job) {
|
if (!bodyshop || !job) {
|
||||||
@@ -1477,9 +1626,11 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
socket.emit("export-failed", {
|
socket.emit("export-failed", {
|
||||||
vendor: "rr",
|
...withRRXmlSocketPayload(error, {
|
||||||
jobId: rid,
|
vendor: "rr",
|
||||||
error: error.message,
|
jobId: rid,
|
||||||
|
error: error.message
|
||||||
|
}),
|
||||||
...cls
|
...cls
|
||||||
});
|
});
|
||||||
socket.emit("rr-user-notice", { jobId: rid, ...cls });
|
socket.emit("rr-user-notice", { jobId: rid, ...cls });
|
||||||
@@ -1541,7 +1692,12 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (finalizeResult?.success) {
|
if (finalizeResult?.success) {
|
||||||
CreateRRLogEvent(socket, "INFO", `{7} Finalize success; marking exported`, { dmsRoNo, outsdRoNo });
|
CreateRRLogEvent(
|
||||||
|
socket,
|
||||||
|
"INFO",
|
||||||
|
`{7} Finalize success; marking exported`,
|
||||||
|
withRRRequestXml(finalizeResult, { dmsRoNo, outsdRoNo })
|
||||||
|
);
|
||||||
|
|
||||||
// ✅ Mark exported + success log
|
// ✅ Mark exported + success log
|
||||||
await markRRExportSuccess({
|
await markRRExportSuccess({
|
||||||
@@ -1584,6 +1740,17 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
message: vendorMessage
|
message: vendorMessage
|
||||||
});
|
});
|
||||||
|
|
||||||
|
CreateRRLogEvent(
|
||||||
|
socket,
|
||||||
|
"ERROR",
|
||||||
|
"Finalize failed",
|
||||||
|
withRRRequestXml(finalizeResult, {
|
||||||
|
roStatus: finalizeResult?.roStatus,
|
||||||
|
statusBlocks: finalizeResult?.statusBlocks,
|
||||||
|
classification: cls
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
await insertRRFailedExportLog({
|
await insertRRFailedExportLog({
|
||||||
socket,
|
socket,
|
||||||
jobId: rid,
|
jobId: rid,
|
||||||
@@ -1595,23 +1762,30 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.emit("export-failed", {
|
socket.emit("export-failed", {
|
||||||
vendor: "rr",
|
...withRRXmlSocketPayload(finalizeResult, {
|
||||||
jobId: rid,
|
vendor: "rr",
|
||||||
error: cls?.friendlyMessage || finalizeResult?.error || "RR finalize failed",
|
jobId: rid,
|
||||||
|
error: cls?.friendlyMessage || finalizeResult?.error || "RR finalize failed"
|
||||||
|
}),
|
||||||
...cls
|
...cls
|
||||||
});
|
});
|
||||||
ack?.({ ok: false, error: cls.friendlyMessage || "RR finalize failed", classification: cls });
|
ack?.({ ok: false, error: cls.friendlyMessage || "RR finalize failed", classification: cls });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const cls = classifyRRVendorError(error);
|
const cls = classifyRRVendorError(error);
|
||||||
CreateRRLogEvent(socket, "ERROR", `Error during RR finalize`, {
|
CreateRRLogEvent(
|
||||||
error: error.message,
|
socket,
|
||||||
vendorStatusCode: cls.vendorStatusCode,
|
"ERROR",
|
||||||
code: cls.errorCode,
|
`Error during RR finalize`,
|
||||||
friendly: cls.friendlyMessage,
|
withRRRequestXml(error, {
|
||||||
stack: error.stack,
|
error: error.message,
|
||||||
jobid: rid
|
vendorStatusCode: cls.vendorStatusCode,
|
||||||
});
|
code: cls.errorCode,
|
||||||
|
friendly: cls.friendlyMessage,
|
||||||
|
stack: error.stack,
|
||||||
|
jobid: rid
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!bodyshop || !job) {
|
if (!bodyshop || !job) {
|
||||||
@@ -1635,7 +1809,17 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message, ...cls });
|
socket.emit(
|
||||||
|
"export-failed",
|
||||||
|
{
|
||||||
|
...withRRXmlSocketPayload(error, {
|
||||||
|
vendor: "rr",
|
||||||
|
jobId: rid,
|
||||||
|
error: error.message
|
||||||
|
}),
|
||||||
|
...cls
|
||||||
|
}
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
//
|
//
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const { buildClientAndOpts, rrCombinedSearch } = require("./rr-lookup");
|
const { buildClientAndOpts, rrCombinedSearch } = require("./rr-lookup");
|
||||||
const CreateRRLogEvent = require("./rr-logger-event");
|
const CreateRRLogEvent = require("./rr-logger-event");
|
||||||
|
const { withRRRequestXml } = require("./rr-log-xml");
|
||||||
/**
|
/**
|
||||||
* Pick and normalize VIN from inputs
|
* Pick and normalize VIN from inputs
|
||||||
* @param vin
|
* @param vin
|
||||||
@@ -168,9 +169,12 @@ const ensureRRServiceVehicle = async (args = {}) => {
|
|||||||
if (bodyshop) {
|
if (bodyshop) {
|
||||||
const combinedSearchResponse = await rrCombinedSearch(bodyshop, { kind: "vin", vin: vinStr, maxResults: 50 });
|
const combinedSearchResponse = await rrCombinedSearch(bodyshop, { kind: "vin", vin: vinStr, maxResults: 50 });
|
||||||
|
|
||||||
CreateRRLogEvent(socket, "silly", "{SV} Preflight combined search by VIN: raw response", {
|
CreateRRLogEvent(
|
||||||
response: combinedSearchResponse
|
socket,
|
||||||
});
|
"silly",
|
||||||
|
"{SV} Preflight combined search by VIN: raw response",
|
||||||
|
withRRRequestXml(combinedSearchResponse, { response: combinedSearchResponse })
|
||||||
|
);
|
||||||
|
|
||||||
owners = ownersFromCombined(combinedSearchResponse, vinStr);
|
owners = ownersFromCombined(combinedSearchResponse, vinStr);
|
||||||
}
|
}
|
||||||
@@ -194,10 +198,15 @@ const ensureRRServiceVehicle = async (args = {}) => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Preflight shouldn't be fatal; log and continue to insert (idempotency will still be handled)
|
// 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", {
|
CreateRRLogEvent(
|
||||||
vin: vinStr,
|
socket,
|
||||||
error: e?.message
|
"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
|
// Vendor says: MODEL DESCRIPTION HAS MAXIMUM LENGTH OF 20
|
||||||
@@ -271,7 +280,7 @@ const ensureRRServiceVehicle = async (args = {}) => {
|
|||||||
try {
|
try {
|
||||||
const res = await client.insertServiceVehicle(insertPayload, insertOpts);
|
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 data = res?.data ?? {};
|
||||||
const svId = data?.dmsRecKey || data?.svId || undefined;
|
const svId = data?.dmsRecKey || data?.svId || undefined;
|
||||||
@@ -309,11 +318,16 @@ const ensureRRServiceVehicle = async (args = {}) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
CreateRRLogEvent(socket, "error", "{SV} insertServiceVehicle: failure", {
|
CreateRRLogEvent(
|
||||||
message: e?.message,
|
socket,
|
||||||
code: e?.code,
|
"error",
|
||||||
status: e?.meta?.status || e?.status
|
"{SV} insertServiceVehicle: failure",
|
||||||
});
|
withRRRequestXml(e, {
|
||||||
|
message: e?.message,
|
||||||
|
code: e?.code,
|
||||||
|
status: e?.meta?.status || e?.status
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user