Compare commits

..

47 Commits

Author SHA1 Message Date
Dave
fcba77fe20 feature/IO-3687-Grey-Scale-Invisible-text - implement 2026-05-06 16:45:00 -04:00
Dave Richer
e0f55b8e7a Merged in feature/IO-3673-Order-Parts-Receive-Bill-Bug (pull request #3222)
feature/IO-3673-Order-Parts-Receive-Bill-Bug - Fix

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

Approved-by: Allan Carr
2026-05-06 15:48:00 +00:00
Dave
88ae1fb1cc feature/IO-3673-Order-Parts-Receive-Bill-Bug - Fix 2026-05-05 13:56:16 -04:00
Dave
c6af2b34b2 feature/IO-3676-Order-As-In-House-Quantity - Fix 2026-05-05 13:41:50 -04:00
Dave Richer
d51dcc0ef2 Merged in feature/IO-3672-Reynolds-Adjustments-V3 (pull request #3219)
feature/IO-3672-Reynolds-Adjustments-V3 - Expand Export logs for Reynolds
2026-05-05 17:31:36 +00:00
Dave
e6178a613d feature/IO-3672-Reynolds-Adjustments-V3 - Expand Export logs for Reynolds 2026-05-05 13:31:03 -04:00
Dave Richer
2a69115903 Merged in feature/IO-3672-Reynolds-Adjustments-V3 (pull request #3217)
feature/IO-3672-Reynolds-Adjustments-V3 - Hide DMS Posting sheet report in reynolds mode.
2026-05-04 21:08:41 +00:00
Dave
c8262da440 feature/IO-3672-Reynolds-Adjustments-V3 - Hide DMS Posting sheet report in reynolds mode. 2026-05-04 16:58:06 -04:00
Dave Richer
1f41a532e2 Merged in feature/IO-3672-Reynolds-Adjustments-V3 (pull request #3215)
feature/IO-3672-Reynolds-Adjustments-V3 - Make sure there is never a scenario where a ROGOG does not have a ROLABOR
2026-05-04 20:32:52 +00:00
Dave
32e67b14b6 feature/IO-3672-Reynolds-Adjustments-V3 - Make sure there is never a scenario where a ROGOG does not have a ROLABOR 2026-05-04 16:31:41 -04:00
Dave Richer
d901004751 Merged in feature/IO-3674-Fix-Save-And-New (pull request #3213)
feature/IO-3674-Fix-Save-And-New - Fix Save and New so state gets reset on form when starting from a new employee
2026-05-04 20:12:59 +00:00
Dave
661e019a4d feature/IO-3674-Fix-Save-And-New - Fix Save and New so state gets reset on form when starting from a new employee 2026-05-04 16:11:54 -04:00
Allan Carr
cd054fcf33 Merged in feature/IO-3650-Pagination-Corrections (pull request #3208)
IO-3650 Pagination Corrections

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

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

Approved-by: Dave Richer
2026-04-29 16:44:46 +00:00
Allan Carr
6242e0f309 IO-3667 - Correction for Styling
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 18:10:49 -07:00
Allan Carr
614420d7d2 IO-3562 Visual Production Board Statistics - Exclude Suspended Jobs
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 18:06:11 -07:00
Allan Carr
3113818a91 IO-3650 Pagination Corrections
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 16:44:23 -07:00
Allan Carr
92a3e57205 IO-3667 Visual Production Title Color
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 15:46:40 -07:00
Dave Richer
de6038038a Merged in hotfix/2026-04-28 (pull request #3206)
hotfix/2026-04-28 - Add Label, fix exported
2026-04-28 18:03:13 +00:00
Dave Richer
8a043767cd Merged in hotfix/2026-04-28 (pull request #3204)
hotfix/2026-04-28 - Add Label, fix exported
2026-04-28 17:52:28 +00:00
Dave
1f8836d9d8 hotfix/2026-04-28 - Add Label, fix exported 2026-04-28 13:51:46 -04:00
Dave Richer
a267d65425 Merged in hotfix/2026-04-21 (pull request #3203)
Hotfix/2026 04 21
2026-04-22 16:44:49 +00:00
Dave Richer
9267e584ff Merged in hotfix/2026-04-21 (pull request #3201)
hotfix/2026-04-21 - fix Parts order comments
2026-04-22 16:43:19 +00:00
Dave
cacda3805a hotfix/2026-04-21 - fix Parts order comments 2026-04-22 12:42:49 -04:00
Dave Richer
69861af88c Merged in feature/IO-3647-Reynolds-Integration-Phase-2-Optional (pull request #3199)
feature/IO-3647-Reynolds-Integration-Phase-2-Optional - Add option to make 'Enhanced Early ROS' optional
2026-04-21 14:52:00 +00:00
Dave
d7294ebba6 feature/IO-3647-Reynolds-Integration-Phase-2-Optional - Add option to make 'Enhanced Early ROS' optional 2026-04-21 10:51:13 -04:00
Dave Richer
d9270102b1 Merged in hotfix/2026-04-21 (pull request #3197)
hotfix/2026-04-21 - Fix save dirty state on employees causing prompt, add 'save and new'
2026-04-21 14:29:33 +00:00
Dave
af757ee71e hotfix/2026-04-21 - Fix save dirty state on employees causing prompt, add 'save and new' 2026-04-21 10:28:26 -04:00
Dave Richer
eb666f2ca1 Merged in hotfix/2026-04-20 (pull request #3195)
hotfix/2026-04-20 - Remove item from Cost centers
2026-04-20 15:57:38 +00:00
Dave Richer
d991e32501 Merged in hotfix/2026-04-20 (pull request #3193)
hotfix/2026-04-20 - Remove item from Cost centers
2026-04-20 15:41:29 +00:00
Dave
2b8990950b hotfix/2026-04-20 - Remove item from Cost centers 2026-04-20 11:40:19 -04:00
Dave Richer
3f2e05befc Merged in release/2026-04-17 (pull request #3192)
Release/2026-04-17 into master-AIO - IO-1366, IO-3624, IO-3638
2026-04-18 02:12:37 +00:00
Dave Richer
06bfdeb449 Merged in feature/IO-3647-Reynolds-Integration-Phase-2 (pull request #3191)
feature/IO-3647-Reynolds-Integration-Phase-2 - Enhance early RO with meaningful amounts.
2026-04-13 15:00:38 +00:00
Dave Richer
66df286ddb Merged in feature/IO-3647-Reynolds-Integration-Phase-2 (pull request #3189)
feature/IO-3647-Reynolds-Integration-Phase-2 - Enhance early RO with meaningful amounts.
2026-04-13 14:40:15 +00:00
Dave Richer
1b2f9fc027 Merged in hotfix/2026-04-10 (pull request #3188)
hotfix/2026-04-10 - Fix Location Identifier in chatter-api
2026-04-10 15:42:43 +00:00
Dave Richer
1287c7ec36 Merged in hotfix/2026-04-10 (pull request #3186)
hotfix/2026-04-10 - Fix Location Identifier in chatter-api
2026-04-10 15:40:04 +00:00
Dave
fb29fa2caa hotfix/2026-04-10 - Fix Location Identifier in chatter-api 2026-04-10 11:38:56 -04:00
Dave
6bda497d8c feature/IO-3647-Reynolds-Integration-Phase-2 - Enhance early RO with meaningful amounts. 2026-04-09 13:54:48 -04:00
Dave Richer
a018b6dc5a Merged in feature/IO-3638-Reynolds-OpenSearch (pull request #3184)
feature/IO-3638-Reynolds-OpenSearch - Add Search on DMS id in Reynolds shops
2026-04-09 15:16:26 +00:00
Dave
8a4679f86c feature/IO-3638-Reynolds-OpenSearch - Add Search on DMS id in Reynolds shops 2026-04-09 11:14:17 -04:00
Dave Richer
4d558da46a Merged in feature/IO-1366-Re-export-Bill-Audit-Log-codex (pull request #3182)
Feature/IO-1366 Re export Bill Audit Log codex audit
2026-04-08 18:02:39 +00:00
Dave Richer
90789e743f Merged in feature/IO-3624-Shop-Config-UX-Refresh (pull request #3180)
Feature/IO-3624 Shop Config UX Refresh
2026-04-03 01:55:24 +00:00
Dave Richer
a4dbc5250e Merged in release/2026-04-03 (pull request #3179)
Release/2026-04-03 - IO-1366, IO-3356, IO-3515, IO-3587, IO-3599, IO-3609, IO-3616, IO-3622, IO-3623, IO-3627, IO-3629, IO-3637
2026-04-03 01:46:11 +00:00
Dave
704543d823 IO-1366 Refine audit trail detail logging 2026-04-02 21:40:59 -04:00
Allan Carr
fe848b5de4 IO-1366 Extend Audit Log
Extend for Time Ticket Changes, Manual Lines, Manual Job, Bill Edits

Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-02 11:06:14 -04:00
56 changed files with 2537 additions and 364 deletions

View File

@@ -13,6 +13,7 @@ import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import dayjs from "../../utils/day";
import { buildBillUpdateAuditDetails } from "../../utils/auditTrailDetails";
import AlertComponent from "../alert/alert.component";
import BillFormContainer from "../bill-form/bill-form.container";
import BillMarkExportedButton from "../bill-mark-exported-button/bill-mark-exported-button.component";
@@ -134,10 +135,16 @@ export function BillDetailEditcontainer({ insertAuditTrail, bodyshop }) {
await Promise.all(updates);
const details = buildBillUpdateAuditDetails({
originalBill: data?.bills_by_pk,
bill,
billlines
});
insertAuditTrail({
jobid: bill.jobid,
jobid: bill.jobid ?? data?.bills_by_pk?.jobid,
billid: search.billid,
operation: AuditTrailMapping.billupdated(bill.invoice_number),
operation: AuditTrailMapping.billupdated(bill.invoice_number, details),
type: "billupdated"
});

View File

@@ -64,7 +64,7 @@ function normalizeJobAllocations(ack) {
* RR-specific DMS Allocations Summary
* Focused on what we actually send to RR:
* - ROGOG (split by taxable / non-taxable segments)
* - ROLABOR shell
* - ROLABOR labor rows with bill hours / rates
*
* The heavy lifting (ROGOG/ROLABOR split, cost allocation, tax flags)
* is now done on the backend via buildRogogFromAllocations/buildRolaborFromRogog.
@@ -181,21 +181,30 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
const rolaborRows = useMemo(() => {
if (!rolaborPreview || !Array.isArray(rolaborPreview.ops)) return [];
return rolaborPreview.ops.map((op, idx) => {
const rowOpCode = opCode || op.opCode;
return rolaborPreview.ops
.filter((op) =>
[op.bill?.jobTotalHrs, op.bill?.billTime, op.bill?.billRate, op.amount?.custPrice, op.amount?.totalAmt]
.map((value) => Number.parseFloat(value ?? "0"))
.some((value) => !Number.isNaN(value) && value !== 0)
)
.map((op, idx) => {
const rowOpCode = opCode || op.opCode;
return {
key: `${op.jobNo}-${idx}`,
opCode: rowOpCode,
jobNo: op.jobNo,
custPayTypeFlag: op.custPayTypeFlag,
custTxblNtxblFlag: op.custTxblNtxblFlag,
payType: op.bill?.payType,
amtType: op.amount?.amtType,
custPrice: op.amount?.custPrice,
totalAmt: op.amount?.totalAmt
};
});
return {
key: `${op.jobNo}-${idx}`,
opCode: rowOpCode,
jobNo: op.jobNo,
custPayTypeFlag: op.custPayTypeFlag,
custTxblNtxblFlag: op.custTxblNtxblFlag,
payType: op.bill?.payType,
jobTotalHrs: op.bill?.jobTotalHrs,
billTime: op.bill?.billTime,
billRate: op.bill?.billRate,
amtType: op.amount?.amtType,
custPrice: op.amount?.custPrice,
totalAmt: op.amount?.totalAmt
};
});
}, [rolaborPreview, opCode]);
// Totals for ROGOG (sum custPrice + dlrCost over all lines)
@@ -245,6 +254,9 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
{ title: "CustPayType", dataIndex: "custPayTypeFlag", key: "custPayTypeFlag" },
{ title: "CustTxblFlag", dataIndex: "custTxblNtxblFlag", key: "custTxblNtxblFlag" },
{ title: "PayType", dataIndex: "payType", key: "payType" },
{ title: "JobTotalHrs", dataIndex: "jobTotalHrs", key: "jobTotalHrs" },
{ title: "BillTime", dataIndex: "billTime", key: "billTime" },
{ title: "BillRate", dataIndex: "billRate", key: "billRate" },
{ title: "AmtType", dataIndex: "amtType", key: "amtType" },
{ title: "CustPrice", dataIndex: "custPrice", key: "custPrice" },
{ title: "TotalAmt", dataIndex: "totalAmt", key: "totalAmt" }
@@ -317,12 +329,13 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
children: (
<>
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
This mirrors the shell that would be sent for ROLABOR when all financials are carried in GOG.
This mirrors the labor rows RR will receive, including weighted bill hours and rates derived from the
job&apos;s labor lines.
</Typography.Paragraph>
<ResponsiveTable
pagination={false}
columns={rolaborColumns}
mobileColumnKeys={["jobNo", "opCode", "breakOut", "itemType"]}
mobileColumnKeys={["jobNo", "opCode", "billRate", "custPrice"]}
rowKey="key"
dataSource={rolaborRows}
locale={{ emptyText: "No ROLABOR lines would be generated." }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,16 +14,20 @@ import CriticalPartsScan from "../../utils/criticalPartsScan";
import UndefinedToNull from "../../utils/undefinedtonull";
import JobLinesUpdsertModal from "./job-lines-upsert-modal.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
import { insertAuditTrail } from "../../redux/application/application.actions.js";
import { buildJobLineInsertAuditDetails, buildJobLineUpdateAuditDetails } from "../../utils/auditTrailDetails.js";
const mapStateToProps = createStructuredSelector({
jobLineEditModal: selectJobLineEditModal,
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("jobLineEdit"))
toggleModalVisible: () => dispatch(toggleModalVisible("jobLineEdit")),
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
});
function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bodyshop }) {
function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bodyshop, insertAuditTrail }) {
const {
treatments: { CriticalPartsScanning }
} = useTreatmentsWithConfig({
@@ -74,6 +78,11 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo
notification.success({
title: t("joblines.successes.created")
});
insertAuditTrail({
jobid: jobLineEditModal.context.jobid,
operation: AuditTrailMapping.jobmanuallineinsert(buildJobLineInsertAuditDetails(values)),
type: "jobmanuallineinsert"
});
} else {
notification.error({
title: t("joblines.errors.creating", {
@@ -103,6 +112,17 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo
notification.success({
title: t("joblines.successes.updated")
});
insertAuditTrail({
jobid: jobLineEditModal.context.jobid,
operation: AuditTrailMapping.joblineupdate(
values.line_desc || jobLineEditModal.context.line_desc || "manual line",
buildJobLineUpdateAuditDetails({
originalLine: jobLineEditModal.context,
values
})
),
type: "joblineupdate"
});
} else {
notification.success({
title: t("joblines.errors.updating", {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ import Jobd3RdPartyModal from "../job-3rd-party-modal/job-3rd-party-modal.compon
import PrintCenterItem from "../print-center-item/print-center-item.component";
import PrintCenterJobsLabels from "../print-center-jobs-labels/print-center-jobs-labels.component";
import PrintCenterSpeedPrint from "../print-center-speed-print/print-center-speed-print.component";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils";
import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import { selectTechnician } from "../../redux/tech/tech.selectors";
const mapStateToProps = createStructuredSelector({
@@ -36,6 +36,8 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
splitKey: bodyshop.imexshopid
});
const hasDMSKey = bodyshopHasDmsKey(bodyshop);
const dmsMode = getDmsMode(bodyshop, "off");
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
const Templates = !hasDMSKey
? Object.keys(tempList)
@@ -60,6 +62,7 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
(temp.regions && temp.regions[bodyshop.region_config]) ||
(temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)
)
.filter((temp) => !isReynoldsMode || !temp.excludedDmsModes?.includes(dmsMode))
.filter((temp) => !technician || temp.group !== "financial");
const JobsReportsList =

View File

@@ -431,6 +431,7 @@ export default function ProductionBoardCard({ technician, card, bodyshop, cardSe
<Card
className={`react-trello-card ${cardSettings.kiosk ? "kiosk-mode" : ""}`}
size="small"
styles={{ header: { backgroundColor: "var(--card-bg-fallback)" } }}
style={{
backgroundColor: cardSettings?.cardcolor
? bgColor.fallback || `rgba(${bgColor.r},${bgColor.g},${bgColor.b},${bgColor.a || 1})`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -810,16 +810,6 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
>
<Input onBlur={handleBlur} />
</Form.Item>
{!hasDMSKey && (
<Form.Item
label={t("bodyshop.fields.responsibilitycenter_accountitem")}
key={`${index}accountitem`}
name={[field.name, "accountitem"]}
rules={[{ required: true }]}
>
<Input onBlur={handleBlur} />
</Form.Item>
)}
{hasDMSKey && !bodyshop.rr_dealerid && (
<>
<Form.Item

View File

@@ -2,7 +2,7 @@ import { PageHeader } from "@ant-design/pro-layout";
import { useMutation, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Form, Modal, Space } from "antd";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -15,21 +15,27 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import dayjs from "../../utils/day";
import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time-tickets-commit-toggle.component";
import TimeTicketModalComponent from "./time-ticket-modal.component";
import { insertAuditTrail } from "../../redux/application/application.actions.js";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { buildTimeTicketAuditSummary } from "../../utils/auditTrailDetails.js";
const mapStateToProps = createStructuredSelector({
timeTicketModal: selectTimeTicket,
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("timeTicket"))
toggleModalVisible: () => dispatch(toggleModalVisible("timeTicket")),
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
});
export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, bodyshop }) {
export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, bodyshop, insertAuditTrail }) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const { t } = useTranslation();
const [enterAgain, setEnterAgain] = useState(false);
const lastSubmittedRef = useRef(null);
const [lineTicketRefreshKey, setLineTicketRefreshKey] = useState(0);
const [insertTicket] = useMutation(INSERT_NEW_TIME_TICKET);
@@ -48,47 +54,77 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
const employees = EmployeeAutoCompleteData?.employees ?? [];
const handleFinish = (values) => {
lastSubmittedRef.current = values;
setLoading(true);
const emps = EmployeeAutoCompleteData?.employees.filter((e) => e.id === values.employeeid);
if (timeTicketModal.context.id) {
updateTicket({
variables: {
timeticketId: timeTicketModal.context.id,
timeticket: {
...values,
rate: emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate : null
}
}
})
.then(handleMutationSuccess)
.catch(handleMutationError);
} else {
//Get selected employee rate.
insertTicket({
variables: {
timeTicketInput: [
{
const isEdit = Boolean(timeTicketModal.context.id);
const emps = employees.filter((employee) => employee.id === values.employeeid);
const mutation = isEdit
? updateTicket({
variables: {
timeticketId: timeTicketModal.context.id,
timeticket: {
...values,
rate:
emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0].rate : null,
bodyshopid: bodyshop.id,
created_by: timeTicketModal.context.created_by
emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate : null
}
]
}
})
.then(handleMutationSuccess)
.catch(handleMutationError);
}
}
})
: insertTicket({
variables: {
timeTicketInput: [
{
...values,
rate:
emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0].rate : null,
bodyshopid: bodyshop.id,
created_by: timeTicketModal.context.created_by
}
]
}
});
mutation.then((result) => handleMutationSuccess(result, isEdit)).catch(handleMutationError);
};
const handleMutationSuccess = () => {
const handleMutationSuccess = (result, isEdit) => {
notification.success({
title: t("timetickets.successes.created")
});
const savedTicket =
result?.data?.update_timetickets?.returning?.[0] ?? result?.data?.insert_timetickets?.returning?.[0] ?? {};
const originalTicket = timeTicketModal.context?.timeticket ?? {};
const submittedValues = {
...(lastSubmittedRef.current ?? {}),
date: lastSubmittedRef.current?.date ?? savedTicket.date ?? originalTicket.date ?? null,
employeeid: lastSubmittedRef.current?.employeeid ?? savedTicket.employeeid ?? originalTicket.employeeid ?? null,
jobid:
lastSubmittedRef.current?.jobid ??
savedTicket.jobid ??
timeTicketModal.context.jobId ??
originalTicket.job?.id ??
originalTicket.jobid ??
null
};
const auditSummary = buildTimeTicketAuditSummary({
originalTicket,
submittedValues,
employees
});
if (auditSummary.jobid) {
insertAuditTrail({
jobid: auditSummary.jobid,
operation: isEdit
? AuditTrailMapping.timeticketupdated(auditSummary.employeeName, auditSummary.date, auditSummary.details)
: AuditTrailMapping.timeticketcreated(auditSummary.employeeName, auditSummary.date, auditSummary.details),
type: isEdit ? "timeticketupdated" : "timeticketcreated"
});
}
// Refresh parent screens (Job Labor tab, etc.)
if (timeTicketModal.actions.refetch) timeTicketModal.actions.refetch();

View File

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

View File

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

View File

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

View File

@@ -8,13 +8,14 @@ import { createStructuredSelector } from "reselect";
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
import { INSERT_NEW_JOB } from "../../graphql/jobs.queries";
import { QUERY_OWNER_FOR_JOB_CREATION } from "../../graphql/owners.queries";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import JobsCreateComponent from "./jobs-create.component";
import JobCreateContext from "./jobs-create.context";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { logImEXEvent } from "../../firebase/firebase.utils";
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -22,10 +23,11 @@ const mapStateToProps = createStructuredSelector({
});
const mapDispatchToProps = (dispatch) => ({
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
setSelectedHeader: (key) => dispatch(setSelectedHeader(key)),
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
});
function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, currentUser }) {
function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, currentUser, insertAuditTrail }) {
const { t } = useTranslation();
const notification = useNotification();
@@ -84,6 +86,11 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr
newJobId: resp.data.insert_jobs.returning[0].id
});
logImEXEvent("manual_job_create_completed", {});
insertAuditTrail({
jobid: resp.data.insert_jobs.returning[0].id,
operation: AuditTrailMapping.jobmanualcreate(),
type: "jobmanualcreate"
});
setIsSubmitting(false);
})
.catch((error) => {

View File

@@ -122,7 +122,7 @@
"billdeleted": "Bill with invoice number {{invoice_number}} deleted.",
"billmarkforreexport": "Bill with invoice number {{invoice_number}} marked for re-export.",
"billposted": "Bill with invoice number {{invoice_number}} posted.",
"billupdated": "Bill with invoice number {{invoice_number}} updated.",
"billupdated": "Bill with invoice number {{invoice_number}} updated with the following details: {{details}}.",
"failedpayment": "Failed payment attempt.",
"jobassignmentchange": "Employee {{name}} assigned to {{operation}}",
"jobassignmentremoved": "Employee assignment removed for {{operation}}",
@@ -137,6 +137,9 @@
"jobintake": "Job intake completed. Status set to {{status}}. Scheduled completion is {{scheduled_completion}}.",
"jobinvoiced": "Job has been invoiced.",
"jobioucreated": "IOU Created.",
"joblineupdate": "Job line {{lineDescription}} updated with the following details: {{details}}.",
"jobmanualcreate": "Job manually created.",
"jobmanuallineinsert": "Job line manually added with the following details: {{details}}.",
"jobmodifylbradj": "Labor adjustments modified {{mod_lbr_ty}} / {{hours}}.",
"jobnoteadded": "Note added to Job.",
"jobnotedeleted": "Note deleted from Job.",
@@ -152,7 +155,9 @@
"tasks_deleted": "Task '{{title}}' deleted by {{deletedBy}}",
"tasks_uncompleted": "Task '{{title}}' uncompleted by {{uncompletedBy}}",
"tasks_undeleted": "Task '{{title}}' undeleted by {{undeletedBy}}",
"tasks_updated": "Task '{{title}}' updated by {{updatedBy}}"
"tasks_updated": "Task '{{title}}' updated by {{updatedBy}}",
"timeticketcreated": "Time Ticket for {{employee}} on {{date}} created with the following details: {{details}}.",
"timeticketupdated": "Time Ticket for {{employee}} on {{date}} updated with the following details: {{details}}"
}
},
"billlines": {
@@ -458,6 +463,7 @@
"md_email_cc": "Auto Email CC: $t(parts_orders.labels.{{template}})",
"md_from_emails": "Additional From Emails",
"md_functionality_toggles": {
"enhanced_early_ros": "Enable Enhance Early ROs",
"parts_queue_toggle": "Auto Add Imported/Supplemented Jobs to Parts Queue"
},
"md_hour_split": {
@@ -1216,6 +1222,7 @@
"confirmdelete": "Are you sure you want to delete these documents. This CANNOT be undone.",
"doctype": "Document Type",
"dragtoupload": "Click or drag files to this area to upload",
"greyscale": "Greyscale",
"newjobid": "Assign to Job",
"openinexplorer": "Open in Explorer",
"optimizedimage": "The below image is optimized. Click on the picture below to open in a new window and view it full size, or open it in explorer.",
@@ -1780,6 +1787,7 @@
},
"jobs": {
"actions": {
"addpayer": "Add Payer",
"addDocuments": "Add Job Documents",
"addNote": "Add Note",
"addtopartsqueue": "Add to Parts Queue",
@@ -3259,6 +3267,7 @@
"information": "Information",
"layout": "Layout",
"statistics": {
"exclude_suspended": "Exclude Suspended Jobs",
"jobs_in_production": "Jobs in Production",
"tasks_in_production": "Tasks in Production",
"tasks_in_view": "Tasks in View",

View File

@@ -122,7 +122,6 @@
"billdeleted": "",
"billposted": "",
"billmarkforreexport": "",
"billupdated": "",
"failedpayment": "",
"jobassignmentchange": "",
"jobassignmentremoved": "",
@@ -458,6 +457,7 @@
"md_email_cc": "",
"md_from_emails": "",
"md_functionality_toggles": {
"enhanced_early_ros": "",
"parts_queue_toggle": ""
},
"md_hour_split": {
@@ -1216,6 +1216,7 @@
"confirmdelete": "",
"doctype": "",
"dragtoupload": "",
"greyscale": "Escala de grises",
"newjobid": "",
"openinexplorer": "",
"optimizedimage": "",
@@ -1780,6 +1781,7 @@
},
"jobs": {
"actions": {
"addpayer": "",
"addDocuments": "Agregar documentos de trabajo",
"addNote": "Añadir la nota",
"addtopartsqueue": "",
@@ -3259,6 +3261,7 @@
"information": "",
"layout": "",
"statistics": {
"exclude_suspended": "",
"jobs_in_production": "",
"tasks_in_production": "",
"tasks_in_view": "",

View File

@@ -122,7 +122,6 @@
"billdeleted": "",
"billmarkforreexport": "",
"billposted": "",
"billupdated": "",
"failedpayment": "",
"jobassignmentchange": "",
"jobassignmentremoved": "",
@@ -458,6 +457,7 @@
"md_email_cc": "",
"md_from_emails": "",
"md_functionality_toggles": {
"enhanced_early_ros": "",
"parts_queue_toggle": ""
},
"md_hour_split": {
@@ -1216,6 +1216,7 @@
"confirmdelete": "",
"doctype": "",
"dragtoupload": "",
"greyscale": "Niveaux de gris",
"newjobid": "",
"openinexplorer": "",
"optimizedimage": "",
@@ -1780,6 +1781,7 @@
},
"jobs": {
"actions": {
"addpayer": "",
"addDocuments": "Ajouter des documents de travail",
"addNote": "Ajouter une note",
"addtopartsqueue": "",
@@ -3259,6 +3261,7 @@
"information": "",
"layout": "",
"statistics": {
"exclude_suspended": "",
"jobs_in_production": "",
"tasks_in_production": "",
"tasks_in_view": "",

View File

@@ -10,7 +10,7 @@ const AuditTrailMapping = {
billdeleted: (invoice_number) => i18n.t("audit_trail.messages.billdeleted", { invoice_number }),
billmarkforreexport: (invoice_number) => i18n.t("audit_trail.messages.billmarkforreexport", { invoice_number }),
billposted: (invoice_number) => i18n.t("audit_trail.messages.billposted", { invoice_number }),
billupdated: (invoice_number) => i18n.t("audit_trail.messages.billupdated", { invoice_number }),
billupdated: (invoice_number, details) => i18n.t("audit_trail.messages.billupdated", { invoice_number, details }),
jobassignmentchange: (operation, name) => i18n.t("audit_trail.messages.jobassignmentchange", { operation, name }),
jobassignmentremoved: (operation) => i18n.t("audit_trail.messages.jobassignmentremoved", { operation }),
jobchecklist: (type, inproduction, status) =>
@@ -26,6 +26,10 @@ const AuditTrailMapping = {
jobinproductionchange: (inproduction) => i18n.t("audit_trail.messages.jobinproductionchange", { inproduction }),
jobinvoiced: () => i18n.t("audit_trail.messages.jobinvoiced"),
jobclosedwithbypass: () => i18n.t("audit_trail.messages.jobclosedwithbypass"),
joblineupdate: (lineDescription, details) =>
i18n.t("audit_trail.messages.joblineupdate", { details, lineDescription }),
jobmanualcreate: () => i18n.t("audit_trail.messages.jobmanualcreate"),
jobmanuallineinsert: (details) => i18n.t("audit_trail.messages.jobmanuallineinsert", { details }),
jobmodifylbradj: ({ mod_lbr_ty, hours }) => i18n.t("audit_trail.messages.jobmodifylbradj", { mod_lbr_ty, hours }),
jobnoteadded: () => i18n.t("audit_trail.messages.jobnoteadded"),
jobnoteupdated: () => i18n.t("audit_trail.messages.jobnoteupdated"),
@@ -72,7 +76,11 @@ const AuditTrailMapping = {
i18n.t("audit_trail.messages.tasks_uncompleted", {
title,
uncompletedBy
})
}),
timeticketcreated: (employee, date, details) =>
i18n.t("audit_trail.messages.timeticketcreated", { employee, date, details }),
timeticketupdated: (employee, date, details) =>
i18n.t("audit_trail.messages.timeticketupdated", { employee, date, details })
};
export default AuditTrailMapping;

View File

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

View File

@@ -0,0 +1,186 @@
import dayjs from "./day";
const EMPTY_VALUE = "<<empty>>";
const NO_CHANGES = "No changes";
const BILL_LINE_KEYS = ["line_desc", "quantity", "actual_price", "actual_cost", "cost_center"];
const JOB_LINE_SKIP_KEYS = new Set(["ah_detail_line", "prt_dsmk_p"]);
const DATE_ONLY_KEYS = new Set(["date"]);
const DATE_TIME_KEYS = new Set(["clockon", "clockoff"]);
const CURRENCY_KEYS = new Set(["actual_price", "actual_cost", "act_price", "db_price", "rate"]);
const HOUR_KEYS = new Set(["productivehrs", "actualhrs", "mod_lb_hrs"]);
const isBlank = (value) => value == null || value === "";
const isStructuredValue = (value) => value != null && typeof value === "object" && !dayjs.isDayjs?.(value);
const formatDate = (value) => (isBlank(value) ? EMPTY_VALUE : dayjs(value).format("YYYY-MM-DD"));
const formatDateTime = (value) => (isBlank(value) ? EMPTY_VALUE : dayjs(value).format("YYYY-MM-DD HH:mm"));
const formatNumber = (value, fractionDigits) =>
typeof value === "number" ? value.toFixed(fractionDigits) : String(value);
const compareValue = (key, value) => {
if (isBlank(value)) return EMPTY_VALUE;
if (DATE_TIME_KEYS.has(key)) return formatDateTime(value);
if (DATE_ONLY_KEYS.has(key)) return formatDate(value);
if (dayjs.isDayjs?.(value)) return formatDateTime(value);
return String(value);
};
const buildFieldChangeDetails = ({ keys, original = {}, updated = {}, displayValue, skippedKeys = new Set() }) =>
keys
.filter((key) => key !== "__typename" && !skippedKeys.has(key))
.filter((key) => !isStructuredValue(original[key]) && !isStructuredValue(updated[key]))
.map((key) => {
if (compareValue(key, original[key]) === compareValue(key, updated[key])) return null;
return `${key}: ${displayValue(key, original[key])} -> ${displayValue(key, updated[key])}`;
})
.filter(Boolean);
const formatBillValue = (key, value) => {
if (isBlank(value)) return EMPTY_VALUE;
if (DATE_ONLY_KEYS.has(key)) return formatDate(value);
if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value);
return String(value);
};
const formatJobLineValue = (key, value) => {
if (isBlank(value)) return EMPTY_VALUE;
if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value);
if (HOUR_KEYS.has(key)) return formatNumber(value, 1);
return String(value);
};
const getEmployeeName = (employeeId, employees = [], fallbackEmployee) => {
if (
(employeeId == null || fallbackEmployee?.id === employeeId) &&
(fallbackEmployee?.first_name || fallbackEmployee?.last_name)
) {
return [fallbackEmployee.first_name, fallbackEmployee.last_name].filter(Boolean).join(" ");
}
const employee = employees.find(({ id }) => id === employeeId);
if (employee) {
return [employee.first_name, employee.last_name].filter(Boolean).join(" ");
}
return employeeId ? String(employeeId) : EMPTY_VALUE;
};
const formatTimeTicketValue = (key, value, { employees = [], fallbackEmployee } = {}) => {
if (isBlank(value)) return EMPTY_VALUE;
if (key === "employeeid") return getEmployeeName(value, employees, fallbackEmployee);
if (DATE_TIME_KEYS.has(key)) return formatDateTime(value);
if (DATE_ONLY_KEYS.has(key)) return formatDate(value);
if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value);
if (HOUR_KEYS.has(key)) return formatNumber(value, 1);
if (typeof value === "boolean") return value ? "true" : "false";
return String(value);
};
const buildBillLineSummary = (line) =>
BILL_LINE_KEYS.map((key) => `${key}: ${formatBillValue(key, line[key])}`).join(", ");
export function buildBillUpdateAuditDetails({ originalBill = {}, bill = {}, billlines = [] }) {
const updatedBill = { ...bill, billlines };
const billKeys = Array.from(new Set([...Object.keys(originalBill), ...Object.keys(updatedBill)])).filter(
(key) => key !== "billlines"
);
const changed = buildFieldChangeDetails({
keys: billKeys,
original: originalBill,
updated: updatedBill,
displayValue: formatBillValue
});
const originalBillLines = originalBill.billlines ?? [];
const updatedBillLines = updatedBill.billlines ?? [];
const addedLines = updatedBillLines
.filter((line) => !line.id)
.map((line) => `+${line.line_desc || line.description || "new line"} (${buildBillLineSummary(line)})`);
const removedLines = originalBillLines
.filter((line) => !updatedBillLines.some((updatedLine) => updatedLine.id === line.id))
.map(
(line) => `-${line.line_desc || line.description || line.id || "removed line"} (${buildBillLineSummary(line)})`
);
const modifiedLines = updatedBillLines
.filter((line) => line.id)
.flatMap((line) => {
const originalLine = originalBillLines.find(({ id }) => id === line.id);
if (!originalLine) return [];
const lineChanges = buildFieldChangeDetails({
keys: BILL_LINE_KEYS,
original: originalLine,
updated: line,
displayValue: formatBillValue
});
if (!lineChanges.length) return [];
return [`${line.line_desc || line.description || line.id}: ${lineChanges.join("; ")}`];
});
if (addedLines.length) changed.push(`billlines added: ${addedLines.join(" | ")}`);
if (removedLines.length) changed.push(`billlines removed: ${removedLines.join(" | ")}`);
if (modifiedLines.length) changed.push(`billlines modified: ${modifiedLines.join(" | ")}`);
return changed.length ? changed.join("; ") : NO_CHANGES;
}
export function buildJobLineInsertAuditDetails(values = {}) {
const details = Object.entries(values)
.filter(([key, value]) => !JOB_LINE_SKIP_KEYS.has(key) && !isBlank(value))
.map(([key, value]) => `${key}: ${formatJobLineValue(key, value)}`);
return details.length ? details.join("; ") : NO_CHANGES;
}
export function buildJobLineUpdateAuditDetails({ originalLine = {}, values = {} }) {
const details = buildFieldChangeDetails({
keys: Object.keys(values),
original: originalLine,
updated: values,
displayValue: formatJobLineValue,
skippedKeys: JOB_LINE_SKIP_KEYS
});
return details.length ? details.join("; ") : NO_CHANGES;
}
export function buildTimeTicketAuditSummary({ originalTicket = {}, submittedValues = {}, employees = [] }) {
const normalizedOriginal = {
...originalTicket,
jobid: originalTicket.job?.id ?? originalTicket.jobid ?? null
};
const details = buildFieldChangeDetails({
keys: Object.keys(submittedValues),
original: normalizedOriginal,
updated: submittedValues,
displayValue: (key, value) =>
formatTimeTicketValue(key, value, {
employees,
fallbackEmployee: key === "employeeid" ? normalizedOriginal.employee : null
})
});
const employeeName = getEmployeeName(
submittedValues.employeeid ?? normalizedOriginal.employeeid,
employees,
normalizedOriginal.employee
);
return {
date: formatDate(submittedValues.date ?? normalizedOriginal.date),
details: details.length ? details.join("; ") : NO_CHANGES,
employeeName,
jobid: submittedValues.jobid ?? normalizedOriginal.jobid ?? null
};
}

View File

@@ -1164,6 +1164,7 @@
- notification_followers
- state
- md_order_statuses
- md_ro_statuses
retry_conf:
interval_sec: 10
num_retries: 0
@@ -1184,7 +1185,8 @@
"new": {
"id": {{$body.event.data.new.id}},
"shopname": {{$body.event.data.new.shopname}},
"md_order_statuses": {{$body.event.data.new.md_order_statuses}}
"md_order_statuses": {{$body.event.data.new.md_order_statuses}},
"md_ro_statuses": {{$body.event.data.new.md_ro_statuses}}
}
},
"op": {{$body.event.op}},

View File

@@ -32,6 +32,7 @@ async function OpenSearchUpdateHandler(req, res) {
clm_no
clm_total
comment
dms_id
ins_co_nm
owner_owing
ownr_co_nm

View File

@@ -250,7 +250,8 @@ async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShop
function buildInteractionPayload(bodyshop, j) {
const isCompany = Boolean(j.ownr_co_nm);
const locationIdentifier = `${bodyshop.chatter_company_id}-${bodyshop.id}`;
const locationIdentifier = bodyshop?.imexshopid ?? `${bodyshop.chatter_company_id}-${bodyshop.id}`;
const timestamp = formatChatterTimestamp(j.actual_delivery, bodyshop.timezone);
if (j.actual_delivery && !timestamp) {

View File

@@ -2442,6 +2442,9 @@ exports.ACTIVE_SHOP_BY_USER = `query ACTIVE_SHOP_BY_USER($user: String) {
associations(where: {active: {_eq: true}, useremail: {_eq: $user}}) {
id
shopid
bodyshop {
rr_dealerid
}
}
}`;
@@ -2953,6 +2956,7 @@ exports.GET_BODYSHOP_BY_ID = `
query GET_BODYSHOP_BY_ID($id: uuid!) {
bodyshops_by_pk(id: $id) {
id
md_ro_statuses
md_order_statuses
shopname
imexshopid

View File

@@ -4,6 +4,7 @@ const queries = require("../graphql-client/queries");
const client = require("../graphql-client/graphql-client").client;
const { pick, isNil } = require("lodash");
const { getClient } = require("../../libs/awsUtils");
const { JOB_DOCUMENT_FIELDS, getGlobalSearchQueryStringFields } = require("./os-search-config");
async function OpenSearchUpdateHandler(req, res) {
try {
@@ -21,27 +22,7 @@ async function OpenSearchUpdateHandler(req, res) {
switch (req.body.table.name) {
case "jobs":
document = pick(req.body.event.data.new, [
"id",
"bodyshopid",
"clm_no",
"clm_total",
"comment",
"ins_co_nm",
"owner_owing",
"ownr_co_nm",
"ownr_fn",
"ownr_ln",
"ownr_ph1",
"ownr_ph2",
"plate_no",
"ro_number",
"status",
"v_model_yr",
"v_make_desc",
"v_model_desc",
"v_vin"
]);
document = pick(req.body.event.data.new, JOB_DOCUMENT_FIELDS);
document.bodyshopid = req.body.event.data.new.shopid;
break;
case "vehicles":
@@ -197,15 +178,18 @@ async function OpenSearchSearchHandler(req, res) {
user: req.user.email
});
if (assocs.length === 0) {
if (assocs.associations.length === 0) {
res.sendStatus(401);
return;
}
const osClient = await getClient();
const activeAssociation = assocs.associations[0];
const bodyShopIdMatchOverride = isNil(process.env.BODY_SHOP_ID_MATCH_OVERRIDE)
? assocs.associations[0].shopid
? activeAssociation.shopid
: process.env.BODY_SHOP_ID_MATCH_OVERRIDE;
const isReynoldsEnabled = Boolean(activeAssociation.bodyshop?.rr_dealerid);
const { body } = await osClient.search({
...(index ? { index } : { index: ["jobs", "vehicles", "owners", "bills", "payments"] }),
@@ -241,21 +225,8 @@ async function OpenSearchSearchHandler(req, res) {
query: `*${search}*`,
// Weighted Fields
fields: [
"*ro_number^20",
"*clm_no^14",
"*v_vin^12",
"*plate_no^12",
"*ownr_ln^10",
"transactionid^10",
"paymentnum^10",
"invoice_number^10",
"*ownr_fn^8",
"*ownr_co_nm^8",
"*ownr_ph1^8",
"*ownr_ph2^8",
"*vendor.name^8",
"*comment^6"
// "*"
...getGlobalSearchQueryStringFields({ isReynoldsEnabled })
// "*"
]
}
}

View File

@@ -0,0 +1,69 @@
/**
* Fields to be included in the job document indexed in OpenSearch. These fields are used for both indexing and
* searching.
* @type {string[]}
*/
const JOB_DOCUMENT_FIELDS = [
"id",
"bodyshopid",
"clm_no",
"clm_total",
"comment",
"dms_id",
"ins_co_nm",
"owner_owing",
"ownr_co_nm",
"ownr_fn",
"ownr_ln",
"ownr_ph1",
"ownr_ph2",
"plate_no",
"ro_number",
"status",
"v_model_yr",
"v_make_desc",
"v_model_desc",
"v_vin"
];
/**
* Fields to be included in the global search query string. These fields are used for constructing the search query.
* @type {string[]}
*/
const BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS = [
"*ro_number^20",
"*clm_no^14",
"*v_vin^12",
"*plate_no^12",
"*ownr_ln^10",
"transactionid^10",
"paymentnum^10",
"invoice_number^10",
"*ownr_fn^8",
"*ownr_co_nm^8",
"*ownr_ph1^8",
"*ownr_ph2^8",
"*vendor.name^8",
"*comment^6"
];
/**
* Returns the fields to be included in the global search query string. If Reynolds is enabled, it includes the dms_id
* field with a higher boost.
* @param param0
* @param param0.isReynoldsEnabled
* @returns {string[]}
*/
const getGlobalSearchQueryStringFields = ({ isReynoldsEnabled = false } = {}) => {
if (!isReynoldsEnabled) {
return BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS;
}
return ["*dms_id^20", ...BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS];
};
module.exports = {
JOB_DOCUMENT_FIELDS,
BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS,
getGlobalSearchQueryStringFields
};

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { JOB_DOCUMENT_FIELDS, BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS, getGlobalSearchQueryStringFields } = require(
"../os-search-config"
);
describe("os-search-config", () => {
it("indexes dms_id on job documents", () => {
expect(JOB_DOCUMENT_FIELDS).toContain("dms_id");
});
it("includes dms_id in global search fields for Reynolds shops", () => {
expect(getGlobalSearchQueryStringFields({ isReynoldsEnabled: true })).toContain("*dms_id^20");
});
it("keeps the default search fields unchanged for non-Reynolds shops", () => {
expect(getGlobalSearchQueryStringFields()).toEqual(BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS);
});
});

View File

@@ -46,6 +46,11 @@ const summarizeAllocationsArray = (arr) =>
cost: summarizeMoney(a.cost)
}));
const toFiniteNumber = (value) => {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
};
/**
* Internal per-center bucket shape for *sales*.
* We keep separate buckets for RR so we can split
@@ -62,6 +67,8 @@ function emptyCenterBucket() {
// Labor
laborTaxableSale: zero, // labor that should be taxed in RR
laborNonTaxableSale: zero, // labor that should NOT be taxed in RR
laborTaxableHours: 0,
laborNonTaxableHours: 0,
// Extras (MAPA/MASH/towing/PAO/etc)
extrasSale: zero, // total extras (taxable + non-taxable)
@@ -453,6 +460,7 @@ function buildProfitCenterHash(job, debugLog, taxContext) {
const rateKey = `rate_${val.mod_lbr_ty.toLowerCase()}`;
const rate = job[rateKey];
const lineHours = toFiniteNumber(val.mod_lb_hrs);
const laborAmount = Dinero({
amount: Math.round(rate * 100)
@@ -460,8 +468,10 @@ function buildProfitCenterHash(job, debugLog, taxContext) {
if (isLaborTaxable(val, taxContext)) {
bucket.laborTaxableSale = bucket.laborTaxableSale.add(laborAmount);
bucket.laborTaxableHours += lineHours;
} else {
bucket.laborNonTaxableSale = bucket.laborNonTaxableSale.add(laborAmount);
bucket.laborNonTaxableHours += lineHours;
}
}
@@ -478,6 +488,8 @@ function buildProfitCenterHash(job, debugLog, taxContext) {
partsNonTaxable: summarizeMoney(b.partsNonTaxableSale),
laborTaxable: summarizeMoney(b.laborTaxableSale),
laborNonTaxable: summarizeMoney(b.laborNonTaxableSale),
laborTaxableHours: b.laborTaxableHours,
laborNonTaxableHours: b.laborNonTaxableHours,
extras: summarizeMoney(b.extrasSale),
extrasTaxable: summarizeMoney(b.extrasTaxableSale),
extrasNonTaxable: summarizeMoney(b.extrasNonTaxableSale)
@@ -916,6 +928,8 @@ function buildJobAllocations(bodyshop, profitCenterHash, costCenterHash, debugLo
// Labor
laborTaxableSale: bucket.laborTaxableSale,
laborNonTaxableSale: bucket.laborNonTaxableSale,
laborTaxableHours: bucket.laborTaxableHours,
laborNonTaxableHours: bucket.laborNonTaxableHours,
// Extras
extrasSale,

View File

@@ -0,0 +1,206 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const mock = require("mock-require");
const graphqlRequestModuleId = require.resolve("graphql-request");
const queriesModuleId = require.resolve("../graphql-client/queries");
const rrLoggerModuleId = require.resolve("./rr-logger-event");
const rrExportLogsModuleId = require.resolve("./rr-export-logs");
const loadExportLogs = ({ requests }) => {
mock.stopAll();
mock(graphqlRequestModuleId, {
GraphQLClient: class MockGraphQLClient {
constructor(endpoint) {
this.endpoint = endpoint;
this.headers = {};
}
setHeaders(headers) {
this.headers = headers;
return this;
}
async request(query, variables) {
requests.push({
endpoint: this.endpoint,
headers: this.headers,
query,
variables
});
return {};
}
}
});
mock(queriesModuleId, {
INSERT_EXPORT_LOG: "INSERT_EXPORT_LOG",
MARK_JOB_EXPORTED: "MARK_JOB_EXPORTED"
});
mock(rrLoggerModuleId, () => {});
delete require.cache[rrExportLogsModuleId];
return require(rrExportLogsModuleId);
};
const socket = {
data: { authToken: "socket-token" },
user: { email: "tech@example.com" }
};
const job = {
id: "job-1",
bodyshop: {
id: "bodyshop-1",
md_ro_statuses: {
default_exported: "Exported"
}
}
};
describe("server/rr/rr-export-logs", () => {
const originalEndpoint = process.env.GRAPHQL_ENDPOINT;
afterEach(() => {
mock.stopAll();
delete require.cache[rrExportLogsModuleId];
process.env.GRAPHQL_ENDPOINT = originalEndpoint;
});
it("marks Reynolds full exports as exported using the shared DMS export mutation", async () => {
process.env.GRAPHQL_ENDPOINT = "https://graphql.example.test/v1/graphql";
const requests = [];
const { markRRExportSuccess } = loadExportLogs({ requests });
await markRRExportSuccess({
socket,
jobId: job.id,
job,
bodyshop: job.bodyshop,
result: {
success: true,
roStatus: {
status: "SUCCESS",
statusCode: "0",
message: "Finalized"
}
},
metaExtra: {
rrPreview: {
provider: "rr",
previewFormat: "rr-rogog-preview.v1",
rogg: {
rows: [{ jobNo: "1", opCode: "BODY", custPrice: "125.00", dlrCost: "50.00" }]
}
}
}
});
expect(requests).toHaveLength(1);
expect(requests[0]).toMatchObject({
endpoint: "https://graphql.example.test/v1/graphql",
headers: { Authorization: "Bearer socket-token" },
query: "MARK_JOB_EXPORTED",
variables: {
jobId: "job-1",
job: {
status: "Exported",
date_exported: expect.any(Date)
},
log: {
bodyshopid: "bodyshop-1",
jobid: "job-1",
successful: true,
useremail: "tech@example.com",
metadata: {
provider: "rr",
rrPreview: {
provider: "rr",
previewFormat: "rr-rogog-preview.v1",
rogg: {
rows: [{ jobNo: "1", opCode: "BODY", custPrice: "125.00", dlrCost: "50.00" }]
}
}
}
},
bill: {
exported: true,
exported_at: expect.any(Date)
}
}
});
});
it("uses the separately loaded bodyshop statuses when job.bodyshop is missing", async () => {
process.env.GRAPHQL_ENDPOINT = "https://graphql.example.test/v1/graphql";
const requests = [];
const { markRRExportSuccess } = loadExportLogs({ requests });
await markRRExportSuccess({
socket,
jobId: job.id,
job: { id: job.id },
bodyshop: job.bodyshop,
result: {
success: true,
roStatus: {
status: "SUCCESS",
statusCode: "0",
message: "Finalized"
}
}
});
expect(requests).toHaveLength(1);
expect(requests[0]).toMatchObject({
query: "MARK_JOB_EXPORTED",
variables: {
jobId: "job-1",
job: {
status: "Exported",
date_exported: expect.any(Date)
},
log: {
bodyshopid: "bodyshop-1",
jobid: "job-1",
successful: true,
useremail: "tech@example.com"
}
}
});
});
it("does not mark Reynolds early RO creation as exported", async () => {
process.env.GRAPHQL_ENDPOINT = "https://graphql.example.test/v1/graphql";
const requests = [];
const { markRRExportSuccess } = loadExportLogs({ requests });
await markRRExportSuccess({
socket,
jobId: job.id,
job,
bodyshop: job.bodyshop,
result: { success: true },
isEarlyRo: true
});
expect(requests).toHaveLength(1);
expect(requests[0]).toMatchObject({
query: "INSERT_EXPORT_LOG",
variables: {
logs: [
{
bodyshopid: "bodyshop-1",
jobid: "job-1",
successful: true,
useremail: "tech@example.com"
}
]
}
});
});
});

View File

@@ -1,10 +1,11 @@
const { buildRRRepairOrderPayload } = require("./rr-job-helpers");
const { buildRRRepairOrderPayload, buildMinimalRolaborFromJob } = require("./rr-job-helpers");
const { buildClientAndOpts } = require("./rr-lookup");
const CreateRRLogEvent = require("./rr-logger-event");
const { withRRRequestXml } = require("./rr-log-xml");
const { buildRRPreviewMetadata } = require("./rr-preview-metadata");
const { extractRrResponsibilityCenters } = require("./rr-responsibility-centers");
const CdkCalculateAllocations = require("./rr-calculate-allocations").default;
const { resolveRROpCodeFromBodyshop } = require("./rr-utils");
const { isEnhancedEarlyROEnabled, resolveRROpCodeFromBodyshop } = require("./rr-utils");
/**
* Derive RR status information from response object.
@@ -56,6 +57,27 @@ const deriveRRStatus = (rrRes = {}) => {
};
};
const resolveRROpCode = (bodyshop, txEnvelope = {}) => {
const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop);
let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null;
if (!opCodeOverride) {
const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null;
const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null;
const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null;
if (opPrefix || opBase || opSuffix) {
const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim();
if (combined) {
opCodeOverride = combined;
}
}
}
if (!opCodeOverride && !resolvedBaseOpCode) return null;
return String(opCodeOverride || resolvedBaseOpCode).trim() || null;
};
/**
* Early RO Creation: Create a minimal RR Repair Order with basic info (customer, advisor, mileage, story).
* Used when creating RO from convert button or admin page before full job export.
@@ -93,7 +115,9 @@ const createMinimalRRRepairOrder = async (args) => {
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null;
// Build minimal RO payload - just header, no allocations/parts/labor
// Build minimal RO payload for early review mode.
// We keep it lightweight, but include a single labor row when we can so Ignite
// exposes the labor subsection for editing.
const cleanVin =
(job?.v_vin || "")
.toString()
@@ -116,6 +140,15 @@ const createMinimalRRRepairOrder = async (args) => {
resolvedMileageIn: mileageIn
});
const enhancedEarlyROEnabled = isEnhancedEarlyROEnabled(bodyshop);
const earlyRoOpCode = enhancedEarlyROEnabled ? resolveRROpCode(bodyshop, txEnvelope) : null;
const earlyRoLabor = enhancedEarlyROEnabled
? buildMinimalRolaborFromJob(job, {
opCode: earlyRoOpCode,
payType: "Cust"
})
: null;
const payload = {
customerNo: String(selected),
advisorNo: String(advisorNo),
@@ -141,14 +174,25 @@ const createMinimalRRRepairOrder = async (args) => {
if (makeOverride) {
payload.makeOverride = makeOverride;
}
if (earlyRoLabor) {
payload.rolabor = earlyRoLabor;
}
CreateRRLogEvent(socket, "INFO", "Creating minimal RR Repair Order (early creation)", {
payload
payload,
enhancedEarlyROEnabled,
earlyRoOpCode,
hasRolabor: !!earlyRoLabor
});
const response = await client.createRepairOrder(payload, finalOpts);
CreateRRLogEvent(socket, "INFO", "RR minimal Repair Order created", withRRRequestXml(response, { payload, response }));
CreateRRLogEvent(
socket,
"INFO",
"RR minimal Repair Order created",
withRRRequestXml(response, { payload, response })
);
const data = response?.data || null;
const statusBlocks = response?.statusBlocks || {};
@@ -221,15 +265,10 @@ const updateRRRepairOrderWithFullData = async (args) => {
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null;
// Optional RR OpCode segments coming from the FE (RRPostForm)
const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null;
const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null;
const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null;
// RR-only extras
let rrCentersConfig = null;
let allocations = null;
let opCode = null;
const opCode = resolveRROpCode(bodyshop, txEnvelope);
// 1) Responsibility center config (for visibility / debugging)
try {
@@ -280,28 +319,9 @@ const updateRRRepairOrderWithFullData = async (args) => {
allocations = [];
}
const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop);
let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null;
// If the FE only sends segments, combine them here.
if (!opCodeOverride && (opPrefix || opBase || opSuffix)) {
const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim();
if (combined) {
opCodeOverride = combined;
}
}
if (opCodeOverride || resolvedBaseOpCode) {
opCode = String(opCodeOverride || resolvedBaseOpCode).trim() || null;
}
CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", {
opCode,
baseFromConfig: resolvedBaseOpCode,
opPrefix,
opBase,
opSuffix
baseFromConfig: resolveRROpCodeFromBodyshop(bodyshop)
});
// Build full RO payload for update with allocations
@@ -321,16 +341,12 @@ const updateRRRepairOrderWithFullData = async (args) => {
payload.roNo = String(roNo);
payload.outsdRoNo = job?.ro_number || job?.id || undefined;
// RR update rejects placeholder non-labor ROLABOR rows with zero labor prices.
// Keep only the actual labor jobs in ROLABOR and let ROGOG carry parts/extras.
// RR update needs a ROLABOR row for every ROGOG JobNo, but rejects zero-price placeholders.
// buildRolaborFromRogog mirrors the GOG price into each row, so keep the full 1:1 set.
if (payload.rolabor?.ops?.length && payload.rogg?.ops?.length) {
const laborJobNos = new Set(
payload.rogg.ops
.filter((op) => op?.segmentKind === "laborTaxable" || op?.segmentKind === "laborNonTaxable")
.map((op) => String(op.jobNo))
);
const roggJobNos = new Set(payload.rogg.ops.map((op) => String(op.jobNo)));
payload.rolabor.ops = payload.rolabor.ops.filter((op) => laborJobNos.has(String(op?.jobNo)));
payload.rolabor.ops = payload.rolabor.ops.filter((op) => roggJobNos.has(String(op?.jobNo)));
if (!payload.rolabor.ops.length) {
delete payload.rolabor;
@@ -377,7 +393,7 @@ const updateRRRepairOrderWithFullData = async (args) => {
success = String(roStatus.status).toUpperCase() === "SUCCESS";
}
return {
const exportResult = {
success,
data,
roStatus,
@@ -387,6 +403,8 @@ const updateRRRepairOrderWithFullData = async (args) => {
roNo: String(roNo),
xml: response?.xml
};
exportResult.rrPreview = buildRRPreviewMetadata({ payload, result: exportResult });
return exportResult;
};
/**
@@ -426,15 +444,10 @@ const exportJobToRR = async (args) => {
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null;
// Optional RR OpCode segments coming from the FE (RRPostForm)
const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null;
const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null;
const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null;
// RR-only extras
let rrCentersConfig = null;
let allocations = null;
let opCode = null;
const opCode = resolveRROpCode(bodyshop, txEnvelope);
// 1) Responsibility center config (for visibility / debugging)
try {
@@ -477,28 +490,9 @@ const exportJobToRR = async (args) => {
allocations = [];
}
const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop);
let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null;
// If the FE only sends segments, combine them here.
if (!opCodeOverride && (opPrefix || opBase || opSuffix)) {
const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim();
if (combined) {
opCodeOverride = combined;
}
}
if (opCodeOverride || resolvedBaseOpCode) {
opCode = String(opCodeOverride || resolvedBaseOpCode).trim() || null;
}
CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", {
opCode,
baseFromConfig: resolvedBaseOpCode,
opPrefix,
opBase,
opSuffix
baseFromConfig: resolveRROpCodeFromBodyshop(bodyshop)
});
// Build RO payload for create.
@@ -542,7 +536,7 @@ const exportJobToRR = async (args) => {
// Extract canonical roNo you'll need for finalize step
const roNo = data?.dmsRoNo ?? data?.outsdRoNo ?? roStatus?.dmsRoNo ?? null;
return {
const exportResult = {
success,
data,
roStatus,
@@ -552,6 +546,8 @@ const exportJobToRR = async (args) => {
roNo,
xml: response?.xml // expose XML for logging/diagnostics
};
exportResult.rrPreview = buildRRPreviewMetadata({ payload, result: exportResult });
return exportResult;
};
/**

View File

@@ -0,0 +1,105 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const mock = require("mock-require");
const helpersModuleId = require.resolve("./rr-job-helpers");
const lookupModuleId = require.resolve("./rr-lookup");
const loggerEventModuleId = require.resolve("./rr-logger-event");
const logXmlModuleId = require.resolve("./rr-log-xml");
const responsibilityCentersModuleId = require.resolve("./rr-responsibility-centers");
const allocationsModuleId = require.resolve("./rr-calculate-allocations");
const jobExportModuleId = require.resolve("./rr-job-export");
const makeBodyshop = (mdFunctionalityToggles) => ({
rr_configuration: {
defaults: {
prefix: "51",
base: "DOZ",
suffix: ""
}
},
...(mdFunctionalityToggles ? { md_functionality_toggles: mdFunctionalityToggles } : {})
});
const makeJob = () => ({
id: "job-1",
ro_number: "RO-123",
v_vin: "1HGBH41JXMN109186",
joblines: [{ mod_lbr_ty: "LAB", mod_lb_hrs: 2, lbr_amt: 200 }]
});
const loadJobExport = ({
buildMinimalRolaborFromJob = vi.fn(() => ({ ops: [{ opCode: "51DOZ" }] })),
createRepairOrder = vi.fn(async () => ({ success: true, data: { dmsRoNo: "12345" } }))
} = {}) => {
mock.stopAll();
mock(helpersModuleId, {
buildRRRepairOrderPayload: vi.fn(),
buildMinimalRolaborFromJob
});
mock(lookupModuleId, {
buildClientAndOpts: () => ({
client: { createRepairOrder },
opts: { envelope: { sender: {} } }
})
});
mock(loggerEventModuleId, vi.fn());
mock(logXmlModuleId, {
withRRRequestXml: (response, payload) => payload
});
mock(responsibilityCentersModuleId, {
extractRrResponsibilityCenters: vi.fn(() => [])
});
mock(allocationsModuleId, {
default: vi.fn()
});
delete require.cache[jobExportModuleId];
return {
...require(jobExportModuleId),
buildMinimalRolaborFromJob,
createRepairOrder
};
};
afterEach(() => {
mock.stopAll();
delete require.cache[jobExportModuleId];
});
describe("server/rr/rr-job-export", () => {
it("sends early RO labor totals by default", async () => {
const { createMinimalRRRepairOrder, createRepairOrder, buildMinimalRolaborFromJob } = loadJobExport();
await createMinimalRRRepairOrder({
bodyshop: makeBodyshop(),
job: makeJob(),
advisorNo: "70754",
selectedCustomer: { custNo: "1134485" },
txEnvelope: {}
});
expect(buildMinimalRolaborFromJob).toHaveBeenCalledWith(makeJob(), {
opCode: "51DOZ",
payType: "Cust"
});
expect(createRepairOrder.mock.calls[0][0].rolabor).toEqual({ ops: [{ opCode: "51DOZ" }] });
});
it("omits early RO labor totals when the shop opts out", async () => {
const { createMinimalRRRepairOrder, createRepairOrder, buildMinimalRolaborFromJob } = loadJobExport();
await createMinimalRRRepairOrder({
bodyshop: makeBodyshop({ enhanced_early_ros: false }),
job: makeJob(),
advisorNo: "70754",
selectedCustomer: { custNo: "1134485" },
txEnvelope: {}
});
expect(buildMinimalRolaborFromJob).not.toHaveBeenCalled();
expect(createRepairOrder.mock.calls[0][0].rolabor).toBeUndefined();
});
});

View File

@@ -52,6 +52,19 @@ const asN2 = (dineroLike) => {
return amount.toFixed(2);
};
const toFiniteNumber = (value) => {
if (typeof value === "number") {
return Number.isFinite(value) ? value : 0;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
};
/**
* Normalize various "money-like" shapes to integer cents.
* Supports:
@@ -100,6 +113,116 @@ const toMoneyCents = (value) => {
const asN2FromCents = (cents) => asN2({ amount: Number.isFinite(cents) ? cents : 0, precision: 2 });
const formatDecimal = (value, maxDecimals = 2) => {
const factor = Math.pow(10, maxDecimals);
const rounded = Math.round(Math.max(0, toFiniteNumber(value)) * factor) / factor;
if (!Number.isFinite(rounded)) return "0";
return rounded.toFixed(maxDecimals).replace(/\.?0+$/, "") || "0";
};
const isLaborSideProfitCenter = (alloc = {}) => {
const pc = alloc?.profitCenter || {};
if (
pc.rr_requires_rolabor ||
pc.rr_force_rolabor ||
pc.rr_labor_side ||
pc.rr_is_labor ||
pc.is_labor
) {
return true;
}
return [alloc.center, pc.name, pc.accountdesc, pc.accountname].some((value) => /\blabou?r\b/i.test(String(value || "")));
};
const buildRolaborBillFields = ({ amountUnits = 0, hours = 0, rate = 0 } = {}) => {
const normalizedAmount = toFiniteNumber(amountUnits);
if (normalizedAmount <= 0) {
return {
jobTotalHrs: "0",
billTime: "0",
billRate: "0"
};
}
let resolvedHours = toFiniteNumber(hours);
let resolvedRate = toFiniteNumber(rate);
if (resolvedHours > 0 && resolvedRate <= 0) {
resolvedRate = normalizedAmount / resolvedHours;
} else if (resolvedRate > 0 && resolvedHours <= 0) {
resolvedHours = normalizedAmount / resolvedRate;
} else if (resolvedHours <= 0 && resolvedRate <= 0) {
// Keep the math internally consistent even if the source job has dollars but no usable hours.
resolvedHours = 1;
resolvedRate = normalizedAmount;
}
return {
jobTotalHrs: formatDecimal(resolvedHours),
billTime: formatDecimal(resolvedHours),
billRate: resolvedRate.toFixed(2)
};
};
const buildMinimalRolaborFromJob = (job, { opCode, payType = "Cust" } = {}) => {
const trimmedOpCode = opCode != null ? String(opCode).trim() : "";
if (!job || !trimmedOpCode) return null;
let totalHours = 0;
let totalAmountUnits = 0;
for (const line of job?.joblines || []) {
const laborType = typeof line?.mod_lbr_ty === "string" ? line.mod_lbr_ty.trim() : "";
if (!laborType) continue;
const lineHours = toFiniteNumber(line?.mod_lb_hrs ?? line?.db_hrs);
const configuredRate = toFiniteNumber(job?.[`rate_${laborType.toLowerCase()}`]);
let lineAmountUnits = toFiniteNumber(line?.lbr_amt);
if (lineAmountUnits <= 0 && lineHours > 0 && configuredRate > 0) {
lineAmountUnits = lineHours * configuredRate;
}
if (lineAmountUnits <= 0 && lineHours <= 0) continue;
totalHours += lineHours;
totalAmountUnits += lineAmountUnits;
}
if (totalAmountUnits <= 0 && totalHours <= 0) return null;
const bill = buildRolaborBillFields({
amountUnits: totalAmountUnits,
hours: totalHours,
rate: totalHours > 0 ? totalAmountUnits / totalHours : 0
});
const formattedAmount = totalAmountUnits.toFixed(2);
return {
ops: [
{
opCode: trimmedOpCode,
jobNo: "1",
custPayTypeFlag: "C",
custTxblNtxblFlag: toFiniteNumber(job?.tax_lbr_rt) > 0 ? "T" : "N",
bill: {
payType,
...bill
},
amount: {
payType,
amtType: "Job",
custPrice: formattedAmount,
totalAmt: formattedAmount
}
}
]
};
};
/**
* Build RR estimate block from allocation totals.
* @param {Array} allocations
@@ -228,6 +351,7 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
const pc = alloc?.profitCenter || {};
const breakOut = pc.rr_gogcode;
const itemType = pc.rr_item_type;
const laborSideProfitCenter = isLaborSideProfitCenter(alloc);
// Only centers configured for RR GOG are included
if (!breakOut || !itemType) continue;
@@ -326,6 +450,15 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
// Each segment becomes its own op / JobNo with a single line
segments.forEach((seg, idx) => {
const jobNo = String(ops.length + 1); // global, 1-based JobNo across all centers/segments
const isLaborSegment = seg.kind === "laborTaxable" || seg.kind === "laborNonTaxable";
const isPartsSegment = seg.kind === "partsTaxable" || seg.kind === "partsNonTaxable";
const rolaborRequired = isLaborSegment || (isPartsSegment && laborSideProfitCenter);
const segmentHours = isLaborSegment
? seg.kind === "laborTaxable"
? toFiniteNumber(alloc.laborTaxableHours)
: toFiniteNumber(alloc.laborNonTaxableHours)
: 0;
const segmentBillRate = isLaborSegment && segmentHours > 0 ? seg.saleCents / 100 / segmentHours : 0;
const line = {
breakOut,
@@ -349,7 +482,10 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
// Extra metadata for UI / debugging
segmentKind: seg.kind,
segmentIndex: idx,
segmentCount
segmentCount,
segmentHours,
segmentBillRate,
rolaborRequired
});
});
}
@@ -368,9 +504,9 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
*
* We still keep a 1:1 mapping with GOG ops: each op gets a corresponding
* OpCodeLaborInfo entry using the same JobNo and the same tax flag as its
* GOG line. Labor-specific hours/rate remain zeroed out, but actual labor
* sale amounts are mirrored into ROLABOR for labor segments so RR receives
* the expected labor pricing on updates. Non-labor ops remain zeroed.
* GOG line. Sale amounts are mirrored into ROLABOR so Reynolds has a
* non-zero job anchor for every ROGOG JobNo; when labor hours are available
* from allocations, weighted bill hours/rates are also populated.
*
* @param {Object} rogg - result of buildRogogFromAllocations
* @param {Object} opts
@@ -390,7 +526,18 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => {
const linePayType = firstLine.custPayTypeFlag || "C";
const isLaborSegment = op.segmentKind === "laborTaxable" || op.segmentKind === "laborNonTaxable";
const laborAmount = isLaborSegment ? String(firstLine?.amount?.custPrice ?? "0") : "0";
const laborAmount = String(firstLine?.amount?.custPrice ?? "0");
const laborBill = isLaborSegment
? buildRolaborBillFields({
amountUnits: laborAmount,
hours: op.segmentHours,
rate: op.segmentBillRate
})
: {
jobTotalHrs: "0",
billTime: "0",
billRate: "0"
};
return {
opCode: op.opCode,
@@ -399,9 +546,7 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => {
custTxblNtxblFlag: txFlag,
bill: {
payType,
jobTotalHrs: "0",
billTime: "0",
billRate: "0"
...laborBill
},
amount: {
payType,
@@ -686,5 +831,6 @@ module.exports = {
normalizeCustomerCandidates,
normalizeVehicleCandidates,
buildRogogFromAllocations,
buildRolaborFromRogog
buildRolaborFromRogog,
buildMinimalRolaborFromJob
};

View File

@@ -0,0 +1,260 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const mock = require("mock-require");
const graphClientModuleId = require.resolve("../graphql-client/graphql-client");
const queriesModuleId = require.resolve("../graphql-client/queries");
const helpersModuleId = require.resolve("./rr-job-helpers");
const loadHelpers = () => {
mock.stopAll();
mock(graphClientModuleId, { client: { request: async () => ({}) } });
mock(queriesModuleId, { GET_JOB_BY_PK: "GET_JOB_BY_PK" });
delete require.cache[helpersModuleId];
return require(helpersModuleId);
};
afterEach(() => {
mock.stopAll();
delete require.cache[helpersModuleId];
});
describe("server/rr/rr-job-helpers", () => {
it("builds a single early-RO labor row from aggregated job labor", () => {
const { buildMinimalRolaborFromJob } = loadHelpers();
const rolabor = buildMinimalRolaborFromJob(
{
tax_lbr_rt: 13,
joblines: [
{ mod_lbr_ty: "LAB", mod_lb_hrs: 2, lbr_amt: 200 },
{ mod_lbr_ty: "LAD", mod_lb_hrs: 1.5, lbr_amt: 180 }
]
},
{ opCode: "51DOZ" }
);
expect(rolabor).toEqual({
ops: [
{
opCode: "51DOZ",
jobNo: "1",
custPayTypeFlag: "C",
custTxblNtxblFlag: "T",
bill: {
payType: "Cust",
jobTotalHrs: "3.5",
billTime: "3.5",
billRate: "108.57"
},
amount: {
payType: "Cust",
amtType: "Job",
custPrice: "380.00",
totalAmt: "380.00"
}
}
]
});
});
it("populates labor bill fields from allocation hours on the full RR payload", () => {
const { buildRRRepairOrderPayload } = loadHelpers();
const payload = buildRRRepairOrderPayload({
job: {
id: "job-1",
ro_number: "RO-123",
v_vin: "1HGBH41JXMN109186"
},
selectedCustomer: { customerNo: "1134485" },
advisorNo: "70754",
allocations: [
{
center: "Body Labor",
partsSale: { amount: 0, precision: 2 },
laborTaxableSale: { amount: 24000, precision: 2 },
laborNonTaxableSale: { amount: 0, precision: 2 },
extrasSale: { amount: 0, precision: 2 },
totalSale: { amount: 24000, precision: 2 },
cost: { amount: 12000, precision: 2 },
laborTaxableHours: 2,
laborNonTaxableHours: 0,
profitCenter: {
rr_gogcode: "BL",
rr_item_type: "G",
accountdesc: "BODY LABOR"
}
}
],
opCode: "51DOZ"
});
expect(payload.rolabor).toEqual({
ops: [
{
opCode: "51DOZ",
jobNo: "1",
custPayTypeFlag: "C",
custTxblNtxblFlag: "T",
bill: {
payType: "Cust",
jobTotalHrs: "2",
billTime: "2",
billRate: "120.00"
},
amount: {
payType: "Cust",
amtType: "Job",
custPrice: "240.00",
totalAmt: "240.00"
}
}
]
});
});
it("mirrors parts assigned to a labor-side RR profit center into ROLABOR", () => {
const { buildRRRepairOrderPayload } = loadHelpers();
const payload = buildRRRepairOrderPayload({
job: {
id: "job-2",
ro_number: "RO-456",
v_vin: "3GCUKHEL3TG292014"
},
selectedCustomer: { customerNo: "411588" },
advisorNo: "70754",
allocations: [
{
center: "Customer Pay CV Labor",
partsSale: { amount: 15000, precision: 2 },
partsTaxableSale: { amount: 0, precision: 2 },
partsNonTaxableSale: { amount: 15000, precision: 2 },
laborTaxableSale: { amount: 0, precision: 2 },
laborNonTaxableSale: { amount: 0, precision: 2 },
extrasSale: { amount: 0, precision: 2 },
extrasTaxableSale: { amount: 0, precision: 2 },
extrasNonTaxableSale: { amount: 0, precision: 2 },
totalSale: { amount: 15000, precision: 2 },
cost: { amount: 0, precision: 2 },
profitCenter: {
rr_gogcode: "VL",
rr_item_type: "P",
accountdesc: "Customer Pay CV Labor"
}
}
],
opCode: "30CVZBDY"
});
expect(payload.rogg.ops[0]).toMatchObject({
opCode: "30CVZBDY",
jobNo: "1",
segmentKind: "partsNonTaxable",
rolaborRequired: true,
lines: [
{
breakOut: "VL",
itemType: "P",
itemDesc: "Customer Pay CV Labor",
custTxblNtxblFlag: "N",
amount: {
custPrice: "150.00",
dlrCost: "0.00"
}
}
]
});
expect(payload.rolabor).toEqual({
ops: [
{
opCode: "30CVZBDY",
jobNo: "1",
custPayTypeFlag: "C",
custTxblNtxblFlag: "N",
bill: {
payType: "Cust",
jobTotalHrs: "0",
billTime: "0",
billRate: "0"
},
amount: {
payType: "Cust",
amtType: "Job",
custPrice: "150.00",
totalAmt: "150.00"
}
}
]
});
});
it("mirrors regular ROGOG parts into ROLABOR so Reynolds can find the JobNo", () => {
const { buildRRRepairOrderPayload } = loadHelpers();
const payload = buildRRRepairOrderPayload({
job: {
id: "job-3",
ro_number: "CDK10131",
v_vin: "3TMLU4EN1AM044343"
},
selectedCustomer: { customerNo: "69158" },
advisorNo: "6224",
allocations: [
{
center: "B/S PARTS",
partsSale: { amount: 15000, precision: 2 },
partsTaxableSale: { amount: 15000, precision: 2 },
partsNonTaxableSale: { amount: 0, precision: 2 },
laborTaxableSale: { amount: 0, precision: 2 },
laborNonTaxableSale: { amount: 0, precision: 2 },
extrasSale: { amount: 0, precision: 2 },
extrasTaxableSale: { amount: 0, precision: 2 },
extrasNonTaxableSale: { amount: 0, precision: 2 },
totalSale: { amount: 15000, precision: 2 },
cost: { amount: 0, precision: 2 },
profitCenter: {
rr_gogcode: "FR",
rr_item_type: "G",
accountdesc: "B/S PARTS"
}
}
],
opCode: "60GMZ"
});
expect(payload.rogg.ops[0]).toMatchObject({
opCode: "60GMZ",
jobNo: "1",
segmentKind: "partsTaxable",
rolaborRequired: false
});
expect(payload.rolabor).toEqual({
ops: [
{
opCode: "60GMZ",
jobNo: "1",
custPayTypeFlag: "C",
custTxblNtxblFlag: "T",
bill: {
payType: "Cust",
jobTotalHrs: "0",
billTime: "0",
billRate: "0"
},
amount: {
payType: "Cust",
amtType: "Job",
custPrice: "150.00",
totalAmt: "150.00"
}
}
]
});
});
});

View File

@@ -0,0 +1,85 @@
const segmentLabelMap = {
partsTaxable: "Parts Taxable",
partsNonTaxable: "Parts Non-Taxable",
extrasTaxable: "Extras Taxable",
extrasNonTaxable: "Extras Non-Taxable",
laborTaxable: "Labor Taxable",
laborNonTaxable: "Labor Non-Taxable"
};
const toCentsFromAmountString = (value) => {
const parsed = Number.parseFloat(value || "0");
return Number.isNaN(parsed) ? 0 : Math.round(parsed * 100);
};
const buildRoggRows = (rogg) => {
if (!rogg || !Array.isArray(rogg.ops)) return [];
const rows = [];
rogg.ops.forEach((op) => {
(op.lines || []).forEach((line, idx) => {
const segmentKind = op.segmentKind;
const segmentCount = op.segmentCount || 0;
const segmentLabel = segmentLabelMap[segmentKind] || segmentKind;
const itemDesc =
segmentCount > 1 && segmentLabel ? `${line.itemDesc} (${segmentLabel})` : line.itemDesc;
rows.push({
key: `${op.jobNo}-${idx}`,
opCode: op.opCode,
jobNo: op.jobNo,
breakOut: line.breakOut,
itemType: line.itemType,
itemDesc,
custQty: line.custQty,
custPayTypeFlag: line.custPayTypeFlag,
custTxblNtxblFlag: line.custTxblNtxblFlag,
custPrice: line.amount?.custPrice,
dlrCost: line.amount?.dlrCost,
segmentKind,
segmentCount
});
});
});
return rows;
};
const buildRoggTotals = (roggRows) => {
const totals = roggRows.reduce(
(acc, row) => {
acc.totalCustPriceCents += toCentsFromAmountString(row.custPrice);
acc.totalDlrCostCents += toCentsFromAmountString(row.dlrCost);
return acc;
},
{ totalCustPriceCents: 0, totalDlrCostCents: 0 }
);
return {
...totals,
totalCustPrice: (totals.totalCustPriceCents / 100).toFixed(2),
totalDlrCost: (totals.totalDlrCostCents / 100).toFixed(2)
};
};
const buildRRPreviewMetadata = ({ payload, result } = {}) => {
const rogg = payload?.rogg || null;
const roggRows = buildRoggRows(rogg);
return {
provider: "rr",
previewFormat: "rr-rogog-preview.v1",
roNo: result?.roNo || payload?.roNo || null,
outsdRoNo: payload?.outsdRoNo || null,
rogg: {
raw: rogg,
rows: roggRows,
totals: buildRoggTotals(roggRows)
}
};
};
module.exports = {
buildRRPreviewMetadata
};

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { buildRRPreviewMetadata } = require("./rr-preview-metadata");
describe("server/rr/rr-preview-metadata", () => {
it("captures ROGOG preview rows and totals", () => {
const metadata = buildRRPreviewMetadata({
payload: {
outsdRoNo: "RO-100",
rogg: {
ops: [
{
opCode: "BODY",
jobNo: "1",
segmentKind: "laborTaxable",
segmentCount: 2,
lines: [
{
breakOut: "B",
itemType: "LAB",
itemDesc: "Body Labor",
custQty: "1.0",
custPayTypeFlag: "C",
custTxblNtxblFlag: "T",
amount: {
custPrice: "125.00",
dlrCost: "50.00"
}
}
]
}
]
}
},
result: { roNo: "12345" }
});
expect(metadata).toMatchObject({
provider: "rr",
previewFormat: "rr-rogog-preview.v1",
roNo: "12345",
outsdRoNo: "RO-100",
rogg: {
rows: [
{
opCode: "BODY",
jobNo: "1",
breakOut: "B",
itemType: "LAB",
itemDesc: "Body Labor (Labor Taxable)",
custTxblNtxblFlag: "T",
custPrice: "125.00",
dlrCost: "50.00"
}
],
totals: {
totalCustPriceCents: 12500,
totalDlrCostCents: 5000,
totalCustPrice: "125.00",
totalDlrCost: "50.00"
}
}
});
expect(metadata).not.toHaveProperty("rolabor");
});
});

View File

@@ -1497,7 +1497,8 @@ const registerRREvents = ({ socket, redisHelpers }) => {
dmsRoNo,
customerNo: String(effectiveCustNo),
advisorNo: String(advisorNo),
vin: job?.v_vin || null
vin: job?.v_vin || null,
rrPreview: result?.rrPreview || null
},
defaultRRTTL
);
@@ -1705,7 +1706,10 @@ const registerRREvents = ({ socket, redisHelpers }) => {
jobId: rid,
job,
bodyshop,
result: finalizeResult
result: finalizeResult,
metaExtra: {
rrPreview: pending?.rrPreview || null
}
});
// Clean pending key

View File

@@ -205,10 +205,19 @@ const resolveRROpCodeFromBodyshop = (bodyshop) => {
return `${prefix}${base}${suffix}`;
};
/**
* Enhanced Early RO labor totals are enabled by default for backwards compatibility.
* Shops can explicitly set md_functionality_toggles.enhanced_early_ros to false to opt out.
* @param bodyshop
* @returns {boolean}
*/
const isEnhancedEarlyROEnabled = (bodyshop) => bodyshop?.md_functionality_toggles?.enhanced_early_ros !== false;
module.exports = {
RRCacheEnums,
defaultRRTTL,
getTransactionType,
isEnhancedEarlyROEnabled,
ownersFromVinBlocks,
makeVehicleSearchPayloadFromJob,
normalizeCustomerCandidates,

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { isEnhancedEarlyROEnabled } = require("./rr-utils");
describe("server/rr/rr-utils", () => {
it("keeps enhanced early ROs enabled when the shop setting is missing", () => {
expect(isEnhancedEarlyROEnabled()).toBe(true);
expect(isEnhancedEarlyROEnabled({})).toBe(true);
expect(isEnhancedEarlyROEnabled({ md_functionality_toggles: {} })).toBe(true);
});
it("only disables enhanced early ROs when the shop explicitly opts out", () => {
expect(isEnhancedEarlyROEnabled({ md_functionality_toggles: { enhanced_early_ros: false } })).toBe(false);
expect(isEnhancedEarlyROEnabled({ md_functionality_toggles: { enhanced_early_ros: true } })).toBe(true);
});
});