Compare commits

...

36 Commits

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

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

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

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

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

Approved-by: Dave Richer
2026-04-29 16:44:46 +00:00
Allan Carr
6242e0f309 IO-3667 - Correction for Styling
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 18:10:49 -07:00
Allan Carr
614420d7d2 IO-3562 Visual Production Board Statistics - Exclude Suspended Jobs
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 18:06:11 -07:00
Allan Carr
3113818a91 IO-3650 Pagination Corrections
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 16:44:23 -07:00
Allan Carr
92a3e57205 IO-3667 Visual Production Title Color
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-28 15:46:40 -07:00
Dave Richer
de6038038a Merged in hotfix/2026-04-28 (pull request #3206)
hotfix/2026-04-28 - Add Label, fix exported
2026-04-28 18:03:13 +00:00
Dave Richer
8a043767cd Merged in hotfix/2026-04-28 (pull request #3204)
hotfix/2026-04-28 - Add Label, fix exported
2026-04-28 17:52:28 +00:00
Dave
1f8836d9d8 hotfix/2026-04-28 - Add Label, fix exported 2026-04-28 13:51:46 -04:00
Dave Richer
a267d65425 Merged in hotfix/2026-04-21 (pull request #3203)
Hotfix/2026 04 21
2026-04-22 16:44:49 +00:00
Dave Richer
9267e584ff Merged in hotfix/2026-04-21 (pull request #3201)
hotfix/2026-04-21 - fix Parts order comments
2026-04-22 16:43:19 +00:00
Dave
cacda3805a hotfix/2026-04-21 - fix Parts order comments 2026-04-22 12:42:49 -04:00
Dave Richer
69861af88c Merged in feature/IO-3647-Reynolds-Integration-Phase-2-Optional (pull request #3199)
feature/IO-3647-Reynolds-Integration-Phase-2-Optional - Add option to make 'Enhanced Early ROS' optional
2026-04-21 14:52:00 +00:00
Dave
d7294ebba6 feature/IO-3647-Reynolds-Integration-Phase-2-Optional - Add option to make 'Enhanced Early ROS' optional 2026-04-21 10:51:13 -04:00
Dave Richer
d9270102b1 Merged in hotfix/2026-04-21 (pull request #3197)
hotfix/2026-04-21 - Fix save dirty state on employees causing prompt, add 'save and new'
2026-04-21 14:29:33 +00:00
Dave
af757ee71e hotfix/2026-04-21 - Fix save dirty state on employees causing prompt, add 'save and new' 2026-04-21 10:28:26 -04:00
Dave Richer
eb666f2ca1 Merged in hotfix/2026-04-20 (pull request #3195)
hotfix/2026-04-20 - Remove item from Cost centers
2026-04-20 15:57:38 +00:00
Dave Richer
d991e32501 Merged in hotfix/2026-04-20 (pull request #3193)
hotfix/2026-04-20 - Remove item from Cost centers
2026-04-20 15:41:29 +00:00
Dave
2b8990950b hotfix/2026-04-20 - Remove item from Cost centers 2026-04-20 11:40:19 -04:00
Dave Richer
3f2e05befc Merged in release/2026-04-17 (pull request #3192)
Release/2026-04-17 into master-AIO - IO-1366, IO-3624, IO-3638
2026-04-18 02:12:37 +00:00
Dave Richer
06bfdeb449 Merged in feature/IO-3647-Reynolds-Integration-Phase-2 (pull request #3191)
feature/IO-3647-Reynolds-Integration-Phase-2 - Enhance early RO with meaningful amounts.
2026-04-13 15:00:38 +00:00
Dave Richer
1b2f9fc027 Merged in hotfix/2026-04-10 (pull request #3188)
hotfix/2026-04-10 - Fix Location Identifier in chatter-api
2026-04-10 15:42:43 +00:00
43 changed files with 1792 additions and 209 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -463,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": {
@@ -1221,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.",
@@ -1785,6 +1787,7 @@
},
"jobs": {
"actions": {
"addpayer": "Add Payer",
"addDocuments": "Add Job Documents",
"addNote": "Add Note",
"addtopartsqueue": "Add to Parts Queue",
@@ -3264,6 +3267,7 @@
"information": "Information",
"layout": "Layout",
"statistics": {
"exclude_suspended": "Exclude Suspended Jobs",
"jobs_in_production": "Jobs in Production",
"tasks_in_production": "Tasks in Production",
"tasks_in_view": "Tasks in View",

View File

@@ -457,6 +457,7 @@
"md_email_cc": "",
"md_from_emails": "",
"md_functionality_toggles": {
"enhanced_early_ros": "",
"parts_queue_toggle": ""
},
"md_hour_split": {
@@ -1215,6 +1216,7 @@
"confirmdelete": "",
"doctype": "",
"dragtoupload": "",
"greyscale": "Escala de grises",
"newjobid": "",
"openinexplorer": "",
"optimizedimage": "",
@@ -1779,6 +1781,7 @@
},
"jobs": {
"actions": {
"addpayer": "",
"addDocuments": "Agregar documentos de trabajo",
"addNote": "Añadir la nota",
"addtopartsqueue": "",
@@ -3258,6 +3261,7 @@
"information": "",
"layout": "",
"statistics": {
"exclude_suspended": "",
"jobs_in_production": "",
"tasks_in_production": "",
"tasks_in_view": "",

View File

@@ -457,6 +457,7 @@
"md_email_cc": "",
"md_from_emails": "",
"md_functionality_toggles": {
"enhanced_early_ros": "",
"parts_queue_toggle": ""
},
"md_hour_split": {
@@ -1215,6 +1216,7 @@
"confirmdelete": "",
"doctype": "",
"dragtoupload": "",
"greyscale": "Niveaux de gris",
"newjobid": "",
"openinexplorer": "",
"optimizedimage": "",
@@ -1779,6 +1781,7 @@
},
"jobs": {
"actions": {
"addpayer": "",
"addDocuments": "Ajouter des documents de travail",
"addNote": "Ajouter une note",
"addtopartsqueue": "",
@@ -3258,6 +3261,7 @@
"information": "",
"layout": "",
"statistics": {
"exclude_suspended": "",
"jobs_in_production": "",
"tasks_in_production": "",
"tasks_in_view": "",

View File

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

View File

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

View File

@@ -2956,6 +2956,7 @@ exports.GET_BODYSHOP_BY_ID = `
query GET_BODYSHOP_BY_ID($id: uuid!) {
bodyshops_by_pk(id: $id) {
id
md_ro_statuses
md_order_statuses
shopname
imexshopid

View File

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

View File

@@ -2,9 +2,10 @@ const { buildRRRepairOrderPayload, buildMinimalRolaborFromJob } = require("./rr-
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.
@@ -139,11 +140,14 @@ const createMinimalRRRepairOrder = async (args) => {
resolvedMileageIn: mileageIn
});
const earlyRoOpCode = resolveRROpCode(bodyshop, txEnvelope);
const earlyRoLabor = buildMinimalRolaborFromJob(job, {
opCode: earlyRoOpCode,
payType: "Cust"
});
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),
@@ -176,13 +180,19 @@ const createMinimalRRRepairOrder = async (args) => {
CreateRRLogEvent(socket, "INFO", "Creating minimal RR Repair Order (early creation)", {
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 || {};
@@ -331,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;
@@ -387,7 +393,7 @@ const updateRRRepairOrderWithFullData = async (args) => {
success = String(roStatus.status).toUpperCase() === "SUCCESS";
}
return {
const exportResult = {
success,
data,
roStatus,
@@ -397,6 +403,8 @@ const updateRRRepairOrderWithFullData = async (args) => {
roNo: String(roNo),
xml: response?.xml
};
exportResult.rrPreview = buildRRPreviewMetadata({ payload, result: exportResult });
return exportResult;
};
/**
@@ -528,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,
@@ -538,6 +546,8 @@ const exportJobToRR = async (args) => {
roNo,
xml: response?.xml // expose XML for logging/diagnostics
};
exportResult.rrPreview = buildRRPreviewMetadata({ payload, result: exportResult });
return exportResult;
};
/**

View File

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

View File

@@ -120,6 +120,22 @@ const formatDecimal = (value, maxDecimals = 2) => {
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);
@@ -335,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;
@@ -434,6 +451,8 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
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)
@@ -465,7 +484,8 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
segmentIndex: idx,
segmentCount,
segmentHours,
segmentBillRate
segmentBillRate,
rolaborRequired
});
});
}
@@ -484,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 sale amounts are mirrored into ROLABOR and, when hours
* are available from allocations, weighted bill hours/rates are also
* populated so the labor subsection is editable in Ignite.
* 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
@@ -506,7 +526,7 @@ 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,

View File

@@ -115,4 +115,146 @@ describe("server/rr/rr-job-helpers", () => {
]
});
});
it("mirrors parts assigned to a labor-side RR profit center into ROLABOR", () => {
const { buildRRRepairOrderPayload } = loadHelpers();
const payload = buildRRRepairOrderPayload({
job: {
id: "job-2",
ro_number: "RO-456",
v_vin: "3GCUKHEL3TG292014"
},
selectedCustomer: { customerNo: "411588" },
advisorNo: "70754",
allocations: [
{
center: "Customer Pay CV Labor",
partsSale: { amount: 15000, precision: 2 },
partsTaxableSale: { amount: 0, precision: 2 },
partsNonTaxableSale: { amount: 15000, precision: 2 },
laborTaxableSale: { amount: 0, precision: 2 },
laborNonTaxableSale: { amount: 0, precision: 2 },
extrasSale: { amount: 0, precision: 2 },
extrasTaxableSale: { amount: 0, precision: 2 },
extrasNonTaxableSale: { amount: 0, precision: 2 },
totalSale: { amount: 15000, precision: 2 },
cost: { amount: 0, precision: 2 },
profitCenter: {
rr_gogcode: "VL",
rr_item_type: "P",
accountdesc: "Customer Pay CV Labor"
}
}
],
opCode: "30CVZBDY"
});
expect(payload.rogg.ops[0]).toMatchObject({
opCode: "30CVZBDY",
jobNo: "1",
segmentKind: "partsNonTaxable",
rolaborRequired: true,
lines: [
{
breakOut: "VL",
itemType: "P",
itemDesc: "Customer Pay CV Labor",
custTxblNtxblFlag: "N",
amount: {
custPrice: "150.00",
dlrCost: "0.00"
}
}
]
});
expect(payload.rolabor).toEqual({
ops: [
{
opCode: "30CVZBDY",
jobNo: "1",
custPayTypeFlag: "C",
custTxblNtxblFlag: "N",
bill: {
payType: "Cust",
jobTotalHrs: "0",
billTime: "0",
billRate: "0"
},
amount: {
payType: "Cust",
amtType: "Job",
custPrice: "150.00",
totalAmt: "150.00"
}
}
]
});
});
it("mirrors regular ROGOG parts into ROLABOR so Reynolds can find the JobNo", () => {
const { buildRRRepairOrderPayload } = loadHelpers();
const payload = buildRRRepairOrderPayload({
job: {
id: "job-3",
ro_number: "CDK10131",
v_vin: "3TMLU4EN1AM044343"
},
selectedCustomer: { customerNo: "69158" },
advisorNo: "6224",
allocations: [
{
center: "B/S PARTS",
partsSale: { amount: 15000, precision: 2 },
partsTaxableSale: { amount: 15000, precision: 2 },
partsNonTaxableSale: { amount: 0, precision: 2 },
laborTaxableSale: { amount: 0, precision: 2 },
laborNonTaxableSale: { amount: 0, precision: 2 },
extrasSale: { amount: 0, precision: 2 },
extrasTaxableSale: { amount: 0, precision: 2 },
extrasNonTaxableSale: { amount: 0, precision: 2 },
totalSale: { amount: 15000, precision: 2 },
cost: { amount: 0, precision: 2 },
profitCenter: {
rr_gogcode: "FR",
rr_item_type: "G",
accountdesc: "B/S PARTS"
}
}
],
opCode: "60GMZ"
});
expect(payload.rogg.ops[0]).toMatchObject({
opCode: "60GMZ",
jobNo: "1",
segmentKind: "partsTaxable",
rolaborRequired: false
});
expect(payload.rolabor).toEqual({
ops: [
{
opCode: "60GMZ",
jobNo: "1",
custPayTypeFlag: "C",
custTxblNtxblFlag: "T",
bill: {
payType: "Cust",
jobTotalHrs: "0",
billTime: "0",
billRate: "0"
},
amount: {
payType: "Cust",
amtType: "Job",
custPrice: "150.00",
totalAmt: "150.00"
}
}
]
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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