Compare commits
47 Commits
feature/IO
...
feature/IO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcba77fe20 | ||
|
|
e0f55b8e7a | ||
|
|
ef6aee0518 | ||
|
|
88ae1fb1cc | ||
|
|
c6af2b34b2 | ||
|
|
d51dcc0ef2 | ||
|
|
e6178a613d | ||
|
|
2a69115903 | ||
|
|
c8262da440 | ||
|
|
1f41a532e2 | ||
|
|
32e67b14b6 | ||
|
|
d901004751 | ||
|
|
661e019a4d | ||
|
|
cd054fcf33 | ||
|
|
5ab54433ff | ||
|
|
62c053ed87 | ||
|
|
6242e0f309 | ||
|
|
614420d7d2 | ||
|
|
3113818a91 | ||
|
|
92a3e57205 | ||
|
|
de6038038a | ||
|
|
8a043767cd | ||
|
|
1f8836d9d8 | ||
|
|
a267d65425 | ||
|
|
9267e584ff | ||
|
|
cacda3805a | ||
|
|
69861af88c | ||
|
|
d7294ebba6 | ||
|
|
d9270102b1 | ||
|
|
af757ee71e | ||
|
|
eb666f2ca1 | ||
|
|
d991e32501 | ||
|
|
2b8990950b | ||
|
|
3f2e05befc | ||
|
|
06bfdeb449 | ||
|
|
66df286ddb | ||
|
|
1b2f9fc027 | ||
|
|
1287c7ec36 | ||
|
|
fb29fa2caa | ||
|
|
6bda497d8c | ||
|
|
a018b6dc5a | ||
|
|
8a4679f86c | ||
|
|
4d558da46a | ||
|
|
90789e743f | ||
|
|
a4dbc5250e | ||
|
|
704543d823 | ||
|
|
fe848b5de4 |
@@ -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"
|
||||
});
|
||||
|
||||
|
||||
@@ -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'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." }}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
123
client/src/components/document-editor/document-editor.utility.js
Normal file
123
client/src/components/document-editor/document-editor.utility.js
Normal 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;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
}));
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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})`
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
186
client/src/utils/auditTrailDetails.js
Normal file
186
client/src/utils/auditTrailDetails.js
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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}},
|
||||
|
||||
@@ -32,6 +32,7 @@ async function OpenSearchUpdateHandler(req, res) {
|
||||
clm_no
|
||||
clm_total
|
||||
comment
|
||||
dms_id
|
||||
ins_co_nm
|
||||
owner_owing
|
||||
ownr_co_nm
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
// "*"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
69
server/opensearch/os-search-config.js
Normal file
69
server/opensearch/os-search-config.js
Normal 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
|
||||
};
|
||||
21
server/opensearch/tests/os-search-config.test.js
Normal file
21
server/opensearch/tests/os-search-config.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
206
server/rr/rr-export-logs.test.js
Normal file
206
server/rr/rr-export-logs.test.js
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
105
server/rr/rr-job-export.test.js
Normal file
105
server/rr/rr-job-export.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
|
||||
260
server/rr/rr-job-helpers.test.js
Normal file
260
server/rr/rr-job-helpers.test.js
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
85
server/rr/rr-preview-metadata.js
Normal file
85
server/rr/rr-preview-metadata.js
Normal 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
|
||||
};
|
||||
68
server/rr/rr-preview-metadata.test.js
Normal file
68
server/rr/rr-preview-metadata.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
18
server/rr/rr-utils.test.js
Normal file
18
server/rr/rr-utils.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user