Merged in release/2026-05-08 (pull request #3236)
Release/2026 05 08 into master-AIO - IO-3562, IO-3647, IO-3650, IO-3667, IO-3672, IO-3673, IO-3674, IO-3676, IO-3679, IO-3686, IO-3687, IO-3688, IO-3689
This commit is contained in:
@@ -27699,6 +27699,27 @@
|
|||||||
</translation>
|
</translation>
|
||||||
</translations>
|
</translations>
|
||||||
</concept_node>
|
</concept_node>
|
||||||
|
<concept_node>
|
||||||
|
<name>addpayer</name>
|
||||||
|
<definition_loaded>false</definition_loaded>
|
||||||
|
<description></description>
|
||||||
|
<comment></comment>
|
||||||
|
<default_text></default_text>
|
||||||
|
<translations>
|
||||||
|
<translation>
|
||||||
|
<language>en-US</language>
|
||||||
|
<approved>false</approved>
|
||||||
|
</translation>
|
||||||
|
<translation>
|
||||||
|
<language>es-MX</language>
|
||||||
|
<approved>false</approved>
|
||||||
|
</translation>
|
||||||
|
<translation>
|
||||||
|
<language>fr-CA</language>
|
||||||
|
<approved>false</approved>
|
||||||
|
</translation>
|
||||||
|
</translations>
|
||||||
|
</concept_node>
|
||||||
<concept_node>
|
<concept_node>
|
||||||
<name>addtopartsqueue</name>
|
<name>addtopartsqueue</name>
|
||||||
<definition_loaded>false</definition_loaded>
|
<definition_loaded>false</definition_loaded>
|
||||||
@@ -31118,6 +31139,27 @@
|
|||||||
<folder_node>
|
<folder_node>
|
||||||
<name>dms</name>
|
<name>dms</name>
|
||||||
<children>
|
<children>
|
||||||
|
<concept_node>
|
||||||
|
<name>IsARCustomer</name>
|
||||||
|
<definition_loaded>false</definition_loaded>
|
||||||
|
<description></description>
|
||||||
|
<comment></comment>
|
||||||
|
<default_text></default_text>
|
||||||
|
<translations>
|
||||||
|
<translation>
|
||||||
|
<language>en-US</language>
|
||||||
|
<approved>false</approved>
|
||||||
|
</translation>
|
||||||
|
<translation>
|
||||||
|
<language>es-MX</language>
|
||||||
|
<approved>false</approved>
|
||||||
|
</translation>
|
||||||
|
<translation>
|
||||||
|
<language>fr-CA</language>
|
||||||
|
<approved>false</approved>
|
||||||
|
</translation>
|
||||||
|
</translations>
|
||||||
|
</concept_node>
|
||||||
<concept_node>
|
<concept_node>
|
||||||
<name>address</name>
|
<name>address</name>
|
||||||
<definition_loaded>false</definition_loaded>
|
<definition_loaded>false</definition_loaded>
|
||||||
@@ -45333,6 +45375,69 @@
|
|||||||
</translation>
|
</translation>
|
||||||
</translations>
|
</translations>
|
||||||
</concept_node>
|
</concept_node>
|
||||||
|
<concept_node>
|
||||||
|
<name>esign-document-completed</name>
|
||||||
|
<definition_loaded>false</definition_loaded>
|
||||||
|
<description></description>
|
||||||
|
<comment></comment>
|
||||||
|
<default_text></default_text>
|
||||||
|
<translations>
|
||||||
|
<translation>
|
||||||
|
<language>en-US</language>
|
||||||
|
<approved>false</approved>
|
||||||
|
</translation>
|
||||||
|
<translation>
|
||||||
|
<language>es-MX</language>
|
||||||
|
<approved>false</approved>
|
||||||
|
</translation>
|
||||||
|
<translation>
|
||||||
|
<language>fr-CA</language>
|
||||||
|
<approved>false</approved>
|
||||||
|
</translation>
|
||||||
|
</translations>
|
||||||
|
</concept_node>
|
||||||
|
<concept_node>
|
||||||
|
<name>esign-document-opened</name>
|
||||||
|
<definition_loaded>false</definition_loaded>
|
||||||
|
<description></description>
|
||||||
|
<comment></comment>
|
||||||
|
<default_text></default_text>
|
||||||
|
<translations>
|
||||||
|
<translation>
|
||||||
|
<language>en-US</language>
|
||||||
|
<approved>false</approved>
|
||||||
|
</translation>
|
||||||
|
<translation>
|
||||||
|
<language>es-MX</language>
|
||||||
|
<approved>false</approved>
|
||||||
|
</translation>
|
||||||
|
<translation>
|
||||||
|
<language>fr-CA</language>
|
||||||
|
<approved>false</approved>
|
||||||
|
</translation>
|
||||||
|
</translations>
|
||||||
|
</concept_node>
|
||||||
|
<concept_node>
|
||||||
|
<name>esign-document-upload-failed</name>
|
||||||
|
<definition_loaded>false</definition_loaded>
|
||||||
|
<description></description>
|
||||||
|
<comment></comment>
|
||||||
|
<default_text></default_text>
|
||||||
|
<translations>
|
||||||
|
<translation>
|
||||||
|
<language>en-US</language>
|
||||||
|
<approved>false</approved>
|
||||||
|
</translation>
|
||||||
|
<translation>
|
||||||
|
<language>es-MX</language>
|
||||||
|
<approved>false</approved>
|
||||||
|
</translation>
|
||||||
|
<translation>
|
||||||
|
<language>fr-CA</language>
|
||||||
|
<approved>false</approved>
|
||||||
|
</translation>
|
||||||
|
</translations>
|
||||||
|
</concept_node>
|
||||||
<concept_node>
|
<concept_node>
|
||||||
<name>intake-delivery-checklist-completed</name>
|
<name>intake-delivery-checklist-completed</name>
|
||||||
<definition_loaded>false</definition_loaded>
|
<definition_loaded>false</definition_loaded>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Button, Col } from "antd";
|
import { Button, Checkbox, Col } from "antd";
|
||||||
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -49,7 +49,13 @@ export default function PBSCustomerSelector({ bodyshop, socket }) {
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: t("jobs.fields.dms.id"), dataIndex: "ContactId", key: "ContactId" },
|
{ title: t("jobs.fields.dms.id"), dataIndex: "Code", key: "ContactId" },
|
||||||
|
{
|
||||||
|
title: t("jobs.fields.dms.IsARCustomer"),
|
||||||
|
dataIndex: "IsARCustomer",
|
||||||
|
key: "IsARCustomer",
|
||||||
|
render: (text, record) => <Checkbox checked={record.IsARCustomer} disabled />
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t("jobs.fields.dms.name1"),
|
title: t("jobs.fields.dms.name1"),
|
||||||
key: "name1",
|
key: "name1",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Result } from "antd";
|
import { Result, theme } from "antd";
|
||||||
import * as markerjs2 from "markerjs2";
|
import * as markerjs2 from "markerjs2";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { handleUpload } from "../documents-local-upload/documents-local-upload.utility";
|
||||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
import {
|
||||||
|
addGreyscaleButtonToMarkerArea,
|
||||||
|
addImageHistoryUndoToMarkerArea,
|
||||||
|
applyGreyscaleToMarkerAreaImage,
|
||||||
|
setMarkerAreaImageSource
|
||||||
|
} from "./document-editor.utility";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
currentUser: selectCurrentUser,
|
currentUser: selectCurrentUser,
|
||||||
@@ -24,7 +30,9 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
|||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
const [imageLoading, setImageLoading] = useState(true);
|
const [imageLoading, setImageLoading] = useState(true);
|
||||||
const markerArea = useRef(null);
|
const markerArea = useRef(null);
|
||||||
|
const imageHistory = useRef([]);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { token } = theme.useToken();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
@@ -32,6 +40,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
|||||||
async (dataUrl) => {
|
async (dataUrl) => {
|
||||||
if (uploading) return;
|
if (uploading) return;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
setLoading(true);
|
||||||
const blob = await b64toBlob(dataUrl);
|
const blob = await b64toBlob(dataUrl);
|
||||||
const nameWithoutExt = filename.split(".").slice(0, -1).join(".").trim();
|
const nameWithoutExt = filename.split(".").slice(0, -1).join(".").trim();
|
||||||
const parts = nameWithoutExt.split("-");
|
const parts = nameWithoutExt.split("-");
|
||||||
@@ -70,6 +79,23 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
|||||||
[filename, jobid, notification, uploading]
|
[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(() => {
|
useEffect(() => {
|
||||||
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
|
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
|
||||||
// create a marker.js MarkerArea
|
// create a marker.js MarkerArea
|
||||||
@@ -93,8 +119,10 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
|||||||
markerArea.current.renderImageQuality = 1;
|
markerArea.current.renderImageQuality = 1;
|
||||||
//markerArea.current.settings.displayMode = "inline";
|
//markerArea.current.settings.displayMode = "inline";
|
||||||
markerArea.current.show();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!imageUrl) return;
|
if (!imageUrl) return;
|
||||||
@@ -106,6 +134,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
|||||||
try {
|
try {
|
||||||
const response = await axios.get(imageUrl, { responseType: "blob", signal: controller.signal });
|
const response = await axios.get(imageUrl, { responseType: "blob", signal: controller.signal });
|
||||||
const blobUrl = URL.createObjectURL(response.data);
|
const blobUrl = URL.createObjectURL(response.data);
|
||||||
|
imageHistory.current = [];
|
||||||
setLoadedImageUrl((prevUrl) => {
|
setLoadedImageUrl((prevUrl) => {
|
||||||
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
||||||
return blobUrl;
|
return blobUrl;
|
||||||
@@ -142,7 +171,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div style={{ background: token.colorBgBase, color: token.colorText, minHeight: "100vh" }}>
|
||||||
{!loading && !uploaded && loadedImageUrl && (
|
{!loading && !uploaded && loadedImageUrl && (
|
||||||
<img
|
<img
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
@@ -158,7 +187,12 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
|||||||
{(loading || imageLoading || !imageLoaded) && !uploaded && (
|
{(loading || imageLoading || !imageLoaded) && !uploaded && (
|
||||||
<LoadingSpinner message={t("documents.labels.uploading")} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
//import "tui-image-editor/dist/tui-image-editor.css";
|
//import "tui-image-editor/dist/tui-image-editor.css";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Result } from "antd";
|
import { Result, theme } from "antd";
|
||||||
import * as markerjs2 from "markerjs2";
|
import * as markerjs2 from "markerjs2";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||||
import { handleUpload } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility.js";
|
import { handleUpload } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility.js";
|
||||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
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({
|
const mapStateToProps = createStructuredSelector({
|
||||||
currentUser: selectCurrentUser,
|
currentUser: selectCurrentUser,
|
||||||
@@ -27,7 +33,9 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
|||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
const [imageLoading, setImageLoading] = useState(true);
|
const [imageLoading, setImageLoading] = useState(true);
|
||||||
const markerArea = useRef(null);
|
const markerArea = useRef(null);
|
||||||
|
const imageHistory = useRef([]);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { token } = theme.useToken();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
const triggerUpload = useCallback(
|
const triggerUpload = useCallback(
|
||||||
@@ -57,6 +65,23 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
|||||||
[bodyshop, currentUser, document, notification]
|
[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(() => {
|
useEffect(() => {
|
||||||
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
|
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
|
||||||
// create a marker.js MarkerArea
|
// create a marker.js MarkerArea
|
||||||
@@ -80,8 +105,10 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
|||||||
markerArea.current.renderImageQuality = 1;
|
markerArea.current.renderImageQuality = 1;
|
||||||
//markerArea.current.settings.displayMode = "inline";
|
//markerArea.current.settings.displayMode = "inline";
|
||||||
markerArea.current.show();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!document?.id) return;
|
if (!document?.id) return;
|
||||||
@@ -100,6 +127,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
const blobUrl = URL.createObjectURL(response.data);
|
const blobUrl = URL.createObjectURL(response.data);
|
||||||
|
imageHistory.current = [];
|
||||||
setImageUrl((prevUrl) => {
|
setImageUrl((prevUrl) => {
|
||||||
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
||||||
return blobUrl;
|
return blobUrl;
|
||||||
@@ -134,7 +162,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div style={{ background: token.colorBgBase, color: token.colorText, minHeight: "100vh" }}>
|
||||||
{!loading && !uploaded && imageUrl && (
|
{!loading && !uploaded && imageUrl && (
|
||||||
<img
|
<img
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
@@ -150,7 +178,12 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
|||||||
{(loading || imageLoading || !imageLoaded) && !uploaded && (
|
{(loading || imageLoading || !imageLoaded) && !uploaded && (
|
||||||
<LoadingSpinner message={t("documents.labels.uploading")} />
|
<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>
|
</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 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 PartsOrderDrawer from "../parts-order-list-table/parts-order-list-table-drawer.component";
|
||||||
import PartsOrderModalContainer from "../parts-order-modal/parts-order-modal.container";
|
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 JobLinesExpander from "./job-lines-expander.component";
|
||||||
import JobLinesPartPriceChange from "./job-lines-part-price-change.component";
|
import JobLinesPartPriceChange from "./job-lines-part-price-change.component";
|
||||||
import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component";
|
import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component";
|
||||||
@@ -595,16 +596,7 @@ export function JobLinesComponent({
|
|||||||
isinhouse: true,
|
isinhouse: true,
|
||||||
date: dayjs(),
|
date: dayjs(),
|
||||||
total: 0,
|
total: 0,
|
||||||
billlines: selectedLines.map((p) => ({
|
billlines: buildInHouseBillLines(selectedLines)
|
||||||
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 }
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -224,14 +224,10 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item name={["ins_co_nm"]} label={t("jobs.fields.ins_co_nm")} rules={[{ required: true }]}>
|
||||||
name={["ins_co_nm"]}
|
|
||||||
label={t("jobs.fields.ins_co_nm")}
|
|
||||||
rules={[{ required: true }]}
|
|
||||||
>
|
|
||||||
<Select
|
<Select
|
||||||
showSearch={{
|
showSearch={{
|
||||||
optionFilterProp:'label'
|
optionFilterProp: "label"
|
||||||
}}
|
}}
|
||||||
options={insuranceOptions}
|
options={insuranceOptions}
|
||||||
/>
|
/>
|
||||||
@@ -250,7 +246,7 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
|
|||||||
label={t("jobs.fields.referralsource")}
|
label={t("jobs.fields.referralsource")}
|
||||||
rules={[{ required: bodyshop.enforce_referral }]}
|
rules={[{ required: bodyshop.enforce_referral }]}
|
||||||
>
|
>
|
||||||
<Select options={referralOptions} />
|
<Select showSearch={{ optionFilterProp: "label" }} options={referralOptions} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
|
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
|
||||||
@@ -272,19 +268,21 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
|
|||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
showSearch={{
|
showSearch={{
|
||||||
optionFilterProp: 'label',
|
optionFilterProp: "label",
|
||||||
filterOption: (input, option) =>
|
filterOption: (input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||||
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
|
||||||
}}
|
}}
|
||||||
style={{ width: 200 }}
|
style={{ width: 200 }}
|
||||||
|
|
||||||
options={csrOptions}
|
options={csrOptions}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{bodyshop.enforce_conversion_category && (
|
{bodyshop.enforce_conversion_category && (
|
||||||
<Form.Item name="category" label={t("jobs.fields.category")} rules={[{ required: bodyshop.enforce_conversion_category }]}>
|
<Form.Item
|
||||||
|
name="category"
|
||||||
|
label={t("jobs.fields.category")}
|
||||||
|
rules={[{ required: bodyshop.enforce_conversion_category }]}
|
||||||
|
>
|
||||||
<Select allowClear options={categoryOptions} />
|
<Select allowClear options={categoryOptions} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -193,6 +193,9 @@ export function JobsCreateJobsInfo({ bodyshop, form, selected }) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("jobs.fields.referralsource")} name="referral_source">
|
<Form.Item label={t("jobs.fields.referralsource")} name="referral_source">
|
||||||
<Select
|
<Select
|
||||||
|
showSearch={{
|
||||||
|
optionFilterProp: "label"
|
||||||
|
}}
|
||||||
options={bodyshop.md_referral_sources.map((s) => ({
|
options={bodyshop.md_referral_sources.map((s) => ({
|
||||||
value: s,
|
value: s,
|
||||||
label: s
|
label: s
|
||||||
|
|||||||
@@ -43,19 +43,25 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
|
|||||||
<Input disabled={jobRO} />
|
<Input disabled={jobRO} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("jobs.fields.ded_status")} name="ded_status">
|
<Form.Item label={t("jobs.fields.ded_status")} name="ded_status">
|
||||||
<Select disabled={jobRO} options={[
|
<Select
|
||||||
{ value: "W", label: t("jobs.labels.deductible.waived") },
|
disabled={jobRO}
|
||||||
{ value: "Y", label: t("jobs.labels.deductible.stands") }
|
options={[
|
||||||
]} />
|
{ value: "W", label: t("jobs.labels.deductible.waived") },
|
||||||
|
{ value: "Y", label: t("jobs.labels.deductible.stands") }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("jobs.fields.ded_amt")} name="ded_amt">
|
<Form.Item label={t("jobs.fields.ded_amt")} name="ded_amt">
|
||||||
<CurrencyInput disabled={jobRO} min={0} />
|
<CurrencyInput disabled={jobRO} min={0} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("jobs.fields.ded_note")} name="ded_note">
|
<Form.Item label={t("jobs.fields.ded_note")} name="ded_note">
|
||||||
<Select disabled={jobRO} options={bodyshop.md_ded_notes.map((n) => ({
|
<Select
|
||||||
value: n,
|
disabled={jobRO}
|
||||||
label: n
|
options={bodyshop.md_ded_notes.map((n) => ({
|
||||||
}))} />
|
value: n,
|
||||||
|
label: n
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("jobs.fields.policy_no")} name="policy_no">
|
<Form.Item label={t("jobs.fields.policy_no")} name="policy_no">
|
||||||
<Input disabled={jobRO} />
|
<Input disabled={jobRO} />
|
||||||
@@ -65,10 +71,14 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label={t("jobs.fields.ins_co_nm")} name="ins_co_nm">
|
<Form.Item label={t("jobs.fields.ins_co_nm")} name="ins_co_nm">
|
||||||
<Select disabled={jobRO} onChange={handleInsCoChange} options={bodyshop.md_ins_cos.map((s) => ({
|
<Select
|
||||||
value: s.name,
|
disabled={jobRO}
|
||||||
label: s.name
|
onChange={handleInsCoChange}
|
||||||
}))} />
|
options={bodyshop.md_ins_cos.map((s) => ({
|
||||||
|
value: s.name,
|
||||||
|
label: s.name
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("jobs.fields.ins_addr1")} name="ins_addr1">
|
<Form.Item label={t("jobs.fields.ins_addr1")} name="ins_addr1">
|
||||||
<Input disabled={jobRO} />
|
<Input disabled={jobRO} />
|
||||||
@@ -119,19 +129,30 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Select disabled={jobRO} allowClear options={bodyshop.md_referral_sources.map((s) => ({
|
<Select
|
||||||
value: s,
|
disabled={jobRO}
|
||||||
label: s
|
allowClear
|
||||||
}))} />
|
showSearch={{
|
||||||
|
optionFilterProp: "label"
|
||||||
|
}}
|
||||||
|
options={bodyshop.md_referral_sources.map((s) => ({
|
||||||
|
value: s,
|
||||||
|
label: s
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
|
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
|
||||||
<Input disabled={jobRO} />
|
<Input disabled={jobRO} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("jobs.fields.alt_transport")} name="alt_transport">
|
<Form.Item label={t("jobs.fields.alt_transport")} name="alt_transport">
|
||||||
<Select disabled={jobRO} allowClear options={bodyshop.appt_alt_transport.map((s) => ({
|
<Select
|
||||||
value: s,
|
disabled={jobRO}
|
||||||
label: s
|
allowClear
|
||||||
}))} />
|
options={bodyshop.appt_alt_transport.map((s) => ({
|
||||||
|
value: s,
|
||||||
|
label: s
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
@@ -233,10 +254,14 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
|
|||||||
</FormRow>
|
</FormRow>
|
||||||
<FormRow header={t("jobs.forms.other")}>
|
<FormRow header={t("jobs.forms.other")}>
|
||||||
<Form.Item label={t("jobs.fields.category")} name="category">
|
<Form.Item label={t("jobs.fields.category")} name="category">
|
||||||
<Select disabled={jobRO} allowClear options={bodyshop.md_categories.map((s) => ({
|
<Select
|
||||||
value: s,
|
disabled={jobRO}
|
||||||
label: s
|
allowClear
|
||||||
}))} />
|
options={bodyshop.md_categories.map((s) => ({
|
||||||
|
value: s,
|
||||||
|
label: s
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
|
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
|
||||||
<Input disabled={jobRO} />
|
<Input disabled={jobRO} />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Button, Card, Col, Row, Space } from "antd";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { isFunction } from "lodash";
|
import { isFunction } from "lodash";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Lightbox from "react-image-lightbox";
|
import Lightbox from "react-image-lightbox";
|
||||||
import "react-image-lightbox/style.css";
|
import "react-image-lightbox/style.css";
|
||||||
@@ -12,12 +12,12 @@ import { createStructuredSelector } from "reselect";
|
|||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import DocumentsUploadImgproxyComponent from "../documents-upload-imgproxy/documents-upload-imgproxy.component";
|
import DocumentsUploadImgproxyComponent from "../documents-upload-imgproxy/documents-upload-imgproxy.component";
|
||||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.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 UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
|
||||||
import JobsDocumentsDownloadButton from "./jobs-document-imgproxy-gallery.download.component";
|
import JobsDocumentsDownloadButton from "./jobs-document-imgproxy-gallery.download.component";
|
||||||
import JobsDocumentsGalleryReassign from "./jobs-document-imgproxy-gallery.reassign.component";
|
import JobsDocumentsGalleryReassign from "./jobs-document-imgproxy-gallery.reassign.component";
|
||||||
import JobsDocumentsDeleteButton from "./jobs-documents-imgproxy-gallery.delete.component";
|
import JobsDocumentsDeleteButton from "./jobs-documents-imgproxy-gallery.delete.component";
|
||||||
import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-imgproxy-gallery.selectall.component";
|
import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-imgproxy-gallery.selectall.component";
|
||||||
import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component";
|
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
@@ -38,6 +38,9 @@ function JobsDocumentsImgproxyComponent({
|
|||||||
const [galleryImages, setGalleryImages] = useState({ images: [], other: [] });
|
const [galleryImages, setGalleryImages] = useState({ images: [], other: [] });
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [modalState, setModalState] = useState({ open: false, index: 0 });
|
const [modalState, setModalState] = useState({ open: false, index: 0 });
|
||||||
|
const [previewUrls, setPreviewUrls] = useState({});
|
||||||
|
const [previewError, setPreviewError] = useState(null);
|
||||||
|
const previewUrlsRef = useRef({});
|
||||||
|
|
||||||
const fetchThumbnails = useCallback(() => {
|
const fetchThumbnails = useCallback(() => {
|
||||||
fetchImgproxyThumbnails({ setStateCallback: setGalleryImages, jobId, billId });
|
fetchImgproxyThumbnails({ setStateCallback: setGalleryImages, jobId, billId });
|
||||||
@@ -49,8 +52,86 @@ function JobsDocumentsImgproxyComponent({
|
|||||||
}
|
}
|
||||||
}, [data, fetchThumbnails]);
|
}, [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 hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" });
|
||||||
const hasMobileAccess = HasFeatureAccess({ bodyshop, featureName: "mobile" });
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
@@ -147,30 +228,33 @@ function JobsDocumentsImgproxyComponent({
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
{modalState.open && (
|
{modalState.open && selectedImage && (
|
||||||
<Lightbox
|
<Lightbox
|
||||||
toolbarButtons={[
|
toolbarButtons={[
|
||||||
<EditFilled
|
<EditFilled
|
||||||
key="edit"
|
key="edit"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newWindow = window.open(
|
openEditorForImage(selectedImage);
|
||||||
`${window.location.protocol}//${window.location.host}/edit?documentId=${
|
|
||||||
galleryImages.images[modalState.index].id
|
|
||||||
}`,
|
|
||||||
"_blank",
|
|
||||||
"noopener,noreferrer"
|
|
||||||
);
|
|
||||||
if (newWindow) newWindow.opener = null;
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
]}
|
]}
|
||||||
mainSrc={galleryImages.images[modalState.index].fullsize}
|
imageLoadErrorMessage={previewError ? t("general.errors.notfound") : undefined}
|
||||||
nextSrc={galleryImages.images[(modalState.index + 1) % galleryImages.images.length].fullsize}
|
mainSrc={previewSrc || selectedImage.src}
|
||||||
prevSrc={
|
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]
|
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={() =>
|
onMovePrevRequest={() =>
|
||||||
setModalState({
|
setModalState({
|
||||||
...modalState,
|
...modalState,
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import ResponsiveTable from "../responsive-table/responsive-table.component";
|
|||||||
|
|
||||||
export default function OwnersListComponent({ loading, owners, total, refetch }) {
|
export default function OwnersListComponent({ loading, owners, total, refetch }) {
|
||||||
const search = queryString.parse(useLocation().search);
|
const search = queryString.parse(useLocation().search);
|
||||||
const {
|
const { page, pageSize } = search;
|
||||||
page
|
|
||||||
// sortcolumn, sortorder
|
|
||||||
} = search;
|
|
||||||
const history = useNavigate();
|
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({
|
const [state, setState] = useState({
|
||||||
sortedInfo: {},
|
sortedInfo: {},
|
||||||
filteredInfo: { text: "" }
|
filteredInfo: { text: "" }
|
||||||
@@ -71,10 +72,14 @@ export default function OwnersListComponent({ loading, owners, total, refetch })
|
|||||||
];
|
];
|
||||||
|
|
||||||
const handleTableChange = (pagination, filters, sorter) => {
|
const handleTableChange = (pagination, filters, sorter) => {
|
||||||
|
const nextPageSize = pagination?.pageSize || currentPageSize;
|
||||||
|
const pageSizeChanged = nextPageSize !== currentPageSize;
|
||||||
|
|
||||||
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
|
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
|
||||||
const updatedSearch = {
|
const updatedSearch = {
|
||||||
...search,
|
...search,
|
||||||
page: pagination.current,
|
pageSize: nextPageSize,
|
||||||
|
page: pageSizeChanged ? 1 : pagination.current,
|
||||||
sortcolumn: sorter.columnKey,
|
sortcolumn: sorter.columnKey,
|
||||||
sortorder: sorter.order
|
sortorder: sorter.order
|
||||||
};
|
};
|
||||||
@@ -119,7 +124,7 @@ export default function OwnersListComponent({ loading, owners, total, refetch })
|
|||||||
>
|
>
|
||||||
<ResponsiveTable
|
<ResponsiveTable
|
||||||
loading={loading}
|
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}
|
columns={columns}
|
||||||
mobileColumnKeys={["name", "ownr_ph1", "ownr_ph2"]}
|
mobileColumnKeys={["name", "ownr_ph1", "ownr_ph2"]}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
@@ -8,14 +8,19 @@ import { pageLimit } from "../../utils/config";
|
|||||||
|
|
||||||
export default function OwnersListContainer() {
|
export default function OwnersListContainer() {
|
||||||
const searchParams = queryString.parse(useLocation().search);
|
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, {
|
const { loading, error, data, refetch } = useQuery(QUERY_ALL_OWNERS_PAGINATED, {
|
||||||
fetchPolicy: "network-only",
|
fetchPolicy: "network-only",
|
||||||
nextFetchPolicy: "network-only",
|
nextFetchPolicy: "network-only",
|
||||||
variables: {
|
variables: {
|
||||||
search: search || "",
|
search: search || "",
|
||||||
offset: page ? (page - 1) * pageLimit : 0,
|
offset: (currentPage - 1) * currentPageSize,
|
||||||
limit: pageLimit,
|
limit: currentPageSize,
|
||||||
order: [
|
order: [
|
||||||
{
|
{
|
||||||
[sortcolumn || "created_at"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc"
|
[sortcolumn || "created_at"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc"
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ export function PartsOrderModalComponent({
|
|||||||
>
|
>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item key={`${index}job_line_id`} name={[field.name, "job_line_id"]} hidden>
|
||||||
|
<Input type="hidden" />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("parts_orders.fields.line_remarks")}
|
label={t("parts_orders.fields.line_remarks")}
|
||||||
key={`${index}line_remarks`}
|
key={`${index}line_remarks`}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
|||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { UPDATE_JOB } from "../../graphql/jobs.queries";
|
import { UPDATE_JOB } from "../../graphql/jobs.queries";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
import { buildSubmittedPartsOrderLines, getSubmittedPartsOrderJobLineIds } from "./parts-order-modal.utils.js";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
currentUser: selectCurrentUser,
|
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.
|
// Force job_line_id from context so it never gets dropped by AntD form submission behavior.
|
||||||
const submittedLines = values?.parts_order_lines?.data ?? [];
|
const submittedLines = values?.parts_order_lines?.data ?? [];
|
||||||
const forcedLines = submittedLines.map((p, index) => {
|
const forcedLines = buildSubmittedPartsOrderLines({
|
||||||
const originalLine = linesToOrder?.[index];
|
submittedLines,
|
||||||
const jobLineId = isReturn ? originalLine?.joblineid : originalLine?.id;
|
linesToOrder,
|
||||||
|
isReturn
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
job_line_id: jobLineId,
|
|
||||||
...(isReturn && { cm_received: false })
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let insertResult;
|
let insertResult;
|
||||||
@@ -147,10 +143,7 @@ export function PartsOrderModalContainer({
|
|||||||
type: isReturn ? "jobspartsreturn" : "jobspartsorder"
|
type: isReturn ? "jobspartsreturn" : "jobspartsorder"
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use linesToOrder from context instead of form values to preserve job line ids
|
const jobLineIds = getSubmittedPartsOrderJobLineIds(forcedLines);
|
||||||
const jobLineIds = (linesToOrder ?? [])
|
|
||||||
.filter((line) => (isReturn ? line.joblineid : line.id))
|
|
||||||
.map((line) => (isReturn ? line.joblineid : line.id));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const jobLinesResult = await updateJobLines({
|
const jobLinesResult = await updateJobLines({
|
||||||
@@ -206,23 +199,20 @@ export function PartsOrderModalContainer({
|
|||||||
isinhouse: true,
|
isinhouse: true,
|
||||||
date: dayjs(),
|
date: dayjs(),
|
||||||
total: 0,
|
total: 0,
|
||||||
billlines: forcedLines.map((p, index) => {
|
billlines: forcedLines.map((p) => ({
|
||||||
const originalLine = linesToOrder?.[index];
|
joblineid: p.job_line_id,
|
||||||
return {
|
actual_price: p.act_price,
|
||||||
joblineid: isReturn ? originalLine?.joblineid : originalLine?.id,
|
actual_cost: 0, // p.act_price,
|
||||||
actual_price: p.act_price,
|
line_desc: p.line_desc,
|
||||||
actual_cost: 0, // p.act_price,
|
line_remarks: p.line_remarks,
|
||||||
line_desc: p.line_desc,
|
part_type: p.part_type,
|
||||||
line_remarks: p.line_remarks,
|
quantity: p.quantity || 1,
|
||||||
part_type: p.part_type,
|
applicable_taxes: {
|
||||||
quantity: p.quantity || 1,
|
local: false,
|
||||||
applicable_taxes: {
|
state: false,
|
||||||
local: false,
|
federal: 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 }) {
|
export function PartsQueueListComponent({ bodyshop }) {
|
||||||
const searchParams = queryString.parse(useLocation().search);
|
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 history = useNavigate();
|
||||||
const [filter, setFilter] = useLocalStorage("filter_parts_queue", null);
|
const [filter, setFilter] = useLocalStorage("filter_parts_queue", null);
|
||||||
const [viewTimeStamp, setViewTimeStamp] = useLocalStorage("parts_queue_timestamps", false);
|
const [viewTimeStamp, setViewTimeStamp] = useLocalStorage("parts_queue_timestamps", false);
|
||||||
@@ -66,7 +69,11 @@ export function PartsQueueListComponent({ bodyshop }) {
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
const handleTableChange = (pagination, filters, sorter) => {
|
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.sortcolumn = sorter.columnKey;
|
||||||
searchParams.sortorder = sorter.order;
|
searchParams.sortorder = sorter.order;
|
||||||
|
|
||||||
@@ -315,9 +322,10 @@ export function PartsQueueListComponent({ bodyshop }) {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{
|
pagination={{
|
||||||
placement: "top",
|
placement: "top",
|
||||||
pageSize: pageLimit
|
pageSize: currentPageSize,
|
||||||
// current: parseInt(page || 1),
|
current: currentPage,
|
||||||
// total: data && data.jobs_aggregate.aggregate.count,
|
showSizeChanger: true,
|
||||||
|
total: jobs.length
|
||||||
}}
|
}}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
mobileColumnKeys={["ro_number", "ownr_ln", "status", "vehicle", "partsstatus"]}
|
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 PrintCenterItem from "../print-center-item/print-center-item.component";
|
||||||
import PrintCenterJobsLabels from "../print-center-jobs-labels/print-center-jobs-labels.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 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";
|
import { selectTechnician } from "../../redux/tech/tech.selectors";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
@@ -36,6 +36,8 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
|
|||||||
splitKey: bodyshop.imexshopid
|
splitKey: bodyshop.imexshopid
|
||||||
});
|
});
|
||||||
const hasDMSKey = bodyshopHasDmsKey(bodyshop);
|
const hasDMSKey = bodyshopHasDmsKey(bodyshop);
|
||||||
|
const dmsMode = getDmsMode(bodyshop, "off");
|
||||||
|
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
|
||||||
|
|
||||||
const Templates = !hasDMSKey
|
const Templates = !hasDMSKey
|
||||||
? Object.keys(tempList)
|
? Object.keys(tempList)
|
||||||
@@ -60,6 +62,7 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
|
|||||||
(temp.regions && temp.regions[bodyshop.region_config]) ||
|
(temp.regions && temp.regions[bodyshop.region_config]) ||
|
||||||
(temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)
|
(temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)
|
||||||
)
|
)
|
||||||
|
.filter((temp) => !isReynoldsMode || !temp.excludedDmsModes?.includes(dmsMode))
|
||||||
.filter((temp) => !technician || temp.group !== "financial");
|
.filter((temp) => !technician || temp.group !== "financial");
|
||||||
|
|
||||||
const JobsReportsList =
|
const JobsReportsList =
|
||||||
|
|||||||
@@ -431,6 +431,7 @@ export default function ProductionBoardCard({ technician, card, bodyshop, cardSe
|
|||||||
<Card
|
<Card
|
||||||
className={`react-trello-card ${cardSettings.kiosk ? "kiosk-mode" : ""}`}
|
className={`react-trello-card ${cardSettings.kiosk ? "kiosk-mode" : ""}`}
|
||||||
size="small"
|
size="small"
|
||||||
|
styles={{ header: { backgroundColor: "var(--card-bg-fallback)" } }}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: cardSettings?.cardcolor
|
backgroundColor: cardSettings?.cardcolor
|
||||||
? bgColor.fallback || `rgba(${bgColor.r},${bgColor.g},${bgColor.b},${bgColor.a || 1})`
|
? 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 { t } = useTranslation();
|
||||||
|
|
||||||
const calculateTotal = (items, key, subKey) => {
|
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) => {
|
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) => {
|
const calculateReducerTotalAmount = (lanes, key) => {
|
||||||
@@ -67,58 +70,83 @@ const ProductionStatistics = ({ data, cardSettings, reducerData }) => {
|
|||||||
return value;
|
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
|
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;
|
: null;
|
||||||
|
|
||||||
const totalLAB = cardSettings.totalLAB
|
const totalLAB = cardSettings.totalLAB
|
||||||
? parseFloat(calculateTotal(data, "labhrs", "mod_lb_hrs").toFixed(2))
|
? parseFloat(calculateTotal(filteredData, "labhrs", "mod_lb_hrs").toFixed(2))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const totalLAR = cardSettings.totalLAR
|
const totalLAR = cardSettings.totalLAR
|
||||||
? parseFloat(calculateTotal(data, "larhrs", "mod_lb_hrs").toFixed(2))
|
? parseFloat(calculateTotal(filteredData, "larhrs", "mod_lb_hrs").toFixed(2))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const jobsInProduction = cardSettings.jobsInProduction ? data.length : null;
|
const jobsInProduction = cardSettings.jobsInProduction ? filteredData.length : null;
|
||||||
|
|
||||||
const totalAmountInProduction = cardSettings.totalAmountInProduction
|
const totalAmountInProduction = cardSettings.totalAmountInProduction
|
||||||
? calculateTotalAmount(data, "job_totals").toFormat("$0,0.00")
|
? calculateTotalAmount(filteredData, "job_totals").toFormat("$0,0.00")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const totalAmountOnBoard = reducerData && cardSettings.totalAmountOnBoard
|
const totalAmountOnBoard =
|
||||||
? calculateReducerTotalAmount(reducerData.lanes, "job_totals").toFormat("$0,0.00")
|
filteredReducerData && cardSettings.totalAmountOnBoard
|
||||||
: null;
|
? calculateReducerTotalAmount(filteredReducerData.lanes, "job_totals").toFormat("$0,0.00")
|
||||||
|
: null;
|
||||||
|
|
||||||
const totalHrsOnBoard = reducerData && cardSettings.totalHrsOnBoard
|
const totalHrsOnBoard =
|
||||||
? parseFloat((
|
filteredReducerData && cardSettings.totalHrsOnBoard
|
||||||
calculateReducerTotal(reducerData.lanes, "labhrs", "mod_lb_hrs") +
|
? parseFloat(
|
||||||
calculateReducerTotal(reducerData.lanes, "larhrs", "mod_lb_hrs")
|
(
|
||||||
).toFixed(2))
|
calculateReducerTotal(filteredReducerData.lanes, "labhrs", "mod_lb_hrs") +
|
||||||
: null;
|
calculateReducerTotal(filteredReducerData.lanes, "larhrs", "mod_lb_hrs")
|
||||||
|
).toFixed(2)
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
const totalLABOnBoard = reducerData && cardSettings.totalLABOnBoard
|
const totalLABOnBoard =
|
||||||
? parseFloat(calculateReducerTotal(reducerData.lanes, "labhrs", "mod_lb_hrs").toFixed(2))
|
filteredReducerData && cardSettings.totalLABOnBoard
|
||||||
: null;
|
? parseFloat(calculateReducerTotal(filteredReducerData.lanes, "labhrs", "mod_lb_hrs").toFixed(2))
|
||||||
|
: null;
|
||||||
|
|
||||||
const totalLAROnBoard = reducerData && cardSettings.totalLAROnBoard
|
const totalLAROnBoard =
|
||||||
? parseFloat(calculateReducerTotal(reducerData.lanes, "larhrs", "mod_lb_hrs").toFixed(2))
|
filteredReducerData && cardSettings.totalLAROnBoard
|
||||||
: null;
|
? parseFloat(calculateReducerTotal(filteredReducerData.lanes, "larhrs", "mod_lb_hrs").toFixed(2))
|
||||||
|
: null;
|
||||||
|
|
||||||
const jobsOnBoard = reducerData && cardSettings.jobsOnBoard
|
const jobsOnBoard =
|
||||||
? reducerData.lanes.reduce((acc, lane) => acc + lane.cards.length, 0)
|
filteredReducerData && cardSettings.jobsOnBoard
|
||||||
: null;
|
? filteredReducerData.lanes.reduce((acc, lane) => acc + lane.cards.length, 0)
|
||||||
|
: null;
|
||||||
|
|
||||||
const tasksInProduction = cardSettings.tasksInProduction
|
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;
|
: null;
|
||||||
|
|
||||||
const tasksOnBoard = reducerData && cardSettings.tasksOnBoard
|
const tasksOnBoard =
|
||||||
? reducerData.lanes.reduce((acc, lane) => {
|
filteredReducerData && cardSettings.tasksOnBoard
|
||||||
return (
|
? filteredReducerData.lanes.reduce((acc, lane) => {
|
||||||
acc + lane.cards.reduce((laneAcc, card) => laneAcc + (card.metadata.tasks_aggregate?.aggregate?.count || 0), 0)
|
return (
|
||||||
);
|
acc +
|
||||||
}, 0)
|
lane.cards.reduce((laneAcc, card) => laneAcc + (card.metadata.tasks_aggregate?.aggregate?.count || 0), 0)
|
||||||
: null;
|
);
|
||||||
|
}, 0)
|
||||||
|
: null;
|
||||||
|
|
||||||
const statistics = mergeStatistics(statisticsItems, [
|
const statistics = mergeStatistics(statisticsItems, [
|
||||||
{ id: 0, value: totalHrs, type: StatisticType.HOURS },
|
{ id: 0, value: totalHrs, type: StatisticType.HOURS },
|
||||||
|
|||||||
@@ -14,7 +14,16 @@ const StatisticsSettings = ({ t, statisticsOrder, setStatisticsOrder, setHasChan
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
<Droppable direction="grid" droppableId="statistics">
|
<Droppable direction="grid" droppableId="statistics">
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ const defaultKanbanSettings = {
|
|||||||
subtotal: false,
|
subtotal: false,
|
||||||
statisticsOrder: statisticsItems.map((item) => item.id),
|
statisticsOrder: statisticsItems.map((item) => item.id),
|
||||||
selectedMdInsCos: [],
|
selectedMdInsCos: [],
|
||||||
selectedEstimators: []
|
selectedEstimators: [],
|
||||||
|
excludeSuspended: false
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultFilters = { search: "", employeeId: null, alert: 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 { selectReportCenter } from "../../redux/modals/modals.selectors";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import DatePickerRanges from "../../utils/DatePickerRanges";
|
import DatePickerRanges from "../../utils/DatePickerRanges";
|
||||||
|
import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
|
||||||
import { GenerateDocument } from "../../utils/RenderTemplate";
|
import { GenerateDocument } from "../../utils/RenderTemplate";
|
||||||
import { TemplateList } from "../../utils/TemplateConstants";
|
import { TemplateList } from "../../utils/TemplateConstants";
|
||||||
import dayjs from "../../utils/day";
|
import dayjs from "../../utils/day";
|
||||||
@@ -48,12 +49,18 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const Templates = TemplateList("report_center");
|
const Templates = TemplateList("report_center");
|
||||||
|
const dmsMode = getDmsMode(bodyshop, "off");
|
||||||
|
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
|
||||||
const ReportsList = Object.keys(Templates)
|
const ReportsList = Object.keys(Templates)
|
||||||
.map((key) => Templates[key])
|
.map((key) => Templates[key])
|
||||||
.filter((temp) => {
|
.filter((temp) => {
|
||||||
const enhancedPayrollOn = Enhanced_Payroll.treatment === "on";
|
const enhancedPayrollOn = Enhanced_Payroll.treatment === "on";
|
||||||
const adpPayrollOn = ADPPayroll.treatment === "on";
|
const adpPayrollOn = ADPPayroll.treatment === "on";
|
||||||
|
|
||||||
|
if (isReynoldsMode && temp.excludedDmsModes?.includes(dmsMode)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (enhancedPayrollOn && adpPayrollOn) {
|
if (enhancedPayrollOn && adpPayrollOn) {
|
||||||
return temp.enhanced_payroll !== false || temp.adp_payroll !== false;
|
return temp.enhanced_payroll !== false || temp.adp_payroll !== false;
|
||||||
}
|
}
|
||||||
@@ -408,6 +415,6 @@ const restrictedReports = [
|
|||||||
{ key: "job_costing_ro_estimator", days: 183 },
|
{ key: "job_costing_ro_estimator", days: 183 },
|
||||||
{ key: "job_lifecycle_date_detail", days: 183 },
|
{ key: "job_lifecycle_date_detail", days: 183 },
|
||||||
{ key: "job_lifecycle_date_summary", days: 183 },
|
{ key: "job_lifecycle_date_summary", days: 183 },
|
||||||
{ key: "customer_list", days: 183 },
|
{ key: "customer_list", days: 736 },
|
||||||
{ key: "customer_list_excel", days: 183 }
|
{ key: "customer_list_excel", days: 736 }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -220,12 +220,16 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
|
|||||||
});
|
});
|
||||||
const savedEmployee = result?.data?.insert_employees?.returning?.[0];
|
const savedEmployee = result?.data?.insert_employees?.returning?.[0];
|
||||||
|
|
||||||
syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues);
|
|
||||||
|
|
||||||
if (submitAction === "saveAndNew") {
|
if (submitAction === "saveAndNew") {
|
||||||
|
if (isNewEmployee) {
|
||||||
|
resetEmployeeFormToCurrentData();
|
||||||
|
}
|
||||||
navigateToEmployee("new");
|
navigateToEmployee("new");
|
||||||
} else if (savedEmployee?.id) {
|
} else if (savedEmployee?.id) {
|
||||||
|
syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues);
|
||||||
navigateToEmployee(savedEmployee.id);
|
navigateToEmployee(savedEmployee.id);
|
||||||
|
} else {
|
||||||
|
syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues);
|
||||||
}
|
}
|
||||||
|
|
||||||
notification.success({
|
notification.success({
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import { Form } from "antd";
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { DELETE_VACATION, INSERT_EMPLOYEES, QUERY_EMPLOYEE_BY_ID, UPDATE_EMPLOYEE } from "../../graphql/employees.queries";
|
import {
|
||||||
|
DELETE_VACATION,
|
||||||
|
INSERT_EMPLOYEES,
|
||||||
|
QUERY_EMPLOYEE_BY_ID,
|
||||||
|
UPDATE_EMPLOYEE
|
||||||
|
} from "../../graphql/employees.queries";
|
||||||
import { ShopEmployeesFormComponent } from "./shop-employees-form.component.jsx";
|
import { ShopEmployeesFormComponent } from "./shop-employees-form.component.jsx";
|
||||||
|
|
||||||
const insertEmployeesMock = vi.fn();
|
const insertEmployeesMock = vi.fn();
|
||||||
@@ -335,6 +340,15 @@ describe("ShopEmployeesFormComponent", () => {
|
|||||||
expect(formInstance.isFieldsTouched()).toBe(false);
|
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({
|
expect(navigateMock).toHaveBeenCalledWith({
|
||||||
search: "employeeId=new"
|
search: "employeeId=new"
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { buildSectionActionButton, renderListOrEmpty } from "../layout-form-row/config-list-actions.utils.jsx";
|
||||||
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
|
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
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 {
|
import {
|
||||||
INLINE_TITLE_GROUP_STYLE,
|
INLINE_TITLE_GROUP_STYLE,
|
||||||
INLINE_TITLE_HANDLE_STYLE,
|
INLINE_TITLE_HANDLE_STYLE,
|
||||||
@@ -25,16 +27,21 @@ import {
|
|||||||
|
|
||||||
const timeZonesList = Intl.supportedValuesOf("timeZone");
|
const timeZonesList = Intl.supportedValuesOf("timeZone");
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({});
|
const mapStateToProps = createStructuredSelector({
|
||||||
|
bodyshop: selectBodyshop
|
||||||
|
});
|
||||||
const mapDispatchToProps = () => ({
|
const mapDispatchToProps = () => ({
|
||||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||||
});
|
});
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoGeneral);
|
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoGeneral);
|
||||||
|
|
||||||
export function ShopInfoGeneral({ form }) {
|
export function ShopInfoGeneral({ form, bodyshop }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insuranceCompanies = Form.useWatch(["md_ins_cos"], form) || [];
|
const insuranceCompanies = Form.useWatch(["md_ins_cos"], form) || [];
|
||||||
const duplicateInsuranceCompanyIndexes = getDuplicateIndexSetByNormalizedName(insuranceCompanies, "name");
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -174,7 +181,9 @@ export function ShopInfoGeneral({ form }) {
|
|||||||
>
|
>
|
||||||
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
|
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
|
||||||
<div style={INLINE_TITLE_SWITCH_GROUP_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">
|
<Form.Item noStyle name={["scoreboard_target", "ignoreblockeddays"]} valuePropName="checked">
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -311,7 +320,12 @@ export function ShopInfoGeneral({ form }) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} xl={8}>
|
<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 />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -478,7 +492,12 @@ export function ShopInfoGeneral({ form }) {
|
|||||||
<div style={INLINE_TITLE_LABEL_STYLE}>
|
<div style={INLINE_TITLE_LABEL_STYLE}>
|
||||||
{t("bodyshop.fields.system_settings.job_costing.use_paint_scale_data")}
|
{t("bodyshop.fields.system_settings.job_costing.use_paint_scale_data")}
|
||||||
</div>
|
</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 />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
@@ -558,7 +577,12 @@ export function ShopInfoGeneral({ form }) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</LayoutFormRow>
|
</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
|
<Form.Item
|
||||||
label={t("general.actions.sharetoteams")}
|
label={t("general.actions.sharetoteams")}
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
@@ -566,6 +590,16 @@ export function ShopInfoGeneral({ form }) {
|
|||||||
>
|
>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</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>
|
</LayoutFormRow>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import ResponsiveTable from "../responsive-table/responsive-table.component";
|
|||||||
|
|
||||||
export default function VehiclesListComponent({ loading, vehicles, total, refetch, basePath = "/manage" }) {
|
export default function VehiclesListComponent({ loading, vehicles, total, refetch, basePath = "/manage" }) {
|
||||||
const search = queryString.parse(useLocation().search);
|
const search = queryString.parse(useLocation().search);
|
||||||
const {
|
const { page, pageSize } = search;
|
||||||
page
|
|
||||||
//sortcolumn, sortorder,
|
|
||||||
} = search;
|
|
||||||
const history = useNavigate();
|
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({
|
const [state, setState] = useState({
|
||||||
sortedInfo: {},
|
sortedInfo: {},
|
||||||
filteredInfo: { text: "" }
|
filteredInfo: { text: "" }
|
||||||
@@ -62,10 +63,14 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc
|
|||||||
];
|
];
|
||||||
|
|
||||||
const handleTableChange = (pagination, filters, sorter) => {
|
const handleTableChange = (pagination, filters, sorter) => {
|
||||||
|
const nextPageSize = pagination?.pageSize || currentPageSize;
|
||||||
|
const pageSizeChanged = nextPageSize !== currentPageSize;
|
||||||
|
|
||||||
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
|
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
|
||||||
const updatedSearch = {
|
const updatedSearch = {
|
||||||
...search,
|
...search,
|
||||||
page: pagination.current,
|
pageSize: nextPageSize,
|
||||||
|
page: pageSizeChanged ? 1 : pagination.current,
|
||||||
sortcolumn: sorter.columnKey,
|
sortcolumn: sorter.columnKey,
|
||||||
sortorder: sorter.order
|
sortorder: sorter.order
|
||||||
};
|
};
|
||||||
@@ -106,7 +111,7 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc
|
|||||||
>
|
>
|
||||||
<ResponsiveTable
|
<ResponsiveTable
|
||||||
loading={loading}
|
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}
|
columns={columns}
|
||||||
mobileColumnKeys={["v_vin", "description", "plate_no"]}
|
mobileColumnKeys={["v_vin", "description", "plate_no"]}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
@@ -16,14 +16,18 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
|
|
||||||
export function VehiclesListContainer({ isPartsEntry }) {
|
export function VehiclesListContainer({ isPartsEntry }) {
|
||||||
const searchParams = queryString.parse(useLocation().search);
|
const searchParams = queryString.parse(useLocation().search);
|
||||||
const { page, sortcolumn, sortorder, search } = searchParams;
|
const { page, sortcolumn, sortorder, search, pageSize } = searchParams;
|
||||||
const basePath = getPartsBasePath(isPartsEntry);
|
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, {
|
const { loading, error, data, refetch } = useQuery(QUERY_ALL_VEHICLES_PAGINATED, {
|
||||||
variables: {
|
variables: {
|
||||||
search: search || "",
|
search: search || "",
|
||||||
offset: page ? (page - 1) * pageLimit : 0,
|
offset: (currentPage - 1) * currentPageSize,
|
||||||
limit: pageLimit,
|
limit: currentPageSize,
|
||||||
order: [
|
order: [
|
||||||
{
|
{
|
||||||
[sortcolumn || "created_at"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc"
|
[sortcolumn || "created_at"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc"
|
||||||
|
|||||||
@@ -18,16 +18,20 @@ const mapStateToProps = createStructuredSelector({});
|
|||||||
|
|
||||||
export function ExportLogsPageComponent() {
|
export function ExportLogsPageComponent() {
|
||||||
const searchParams = queryString.parse(useLocation().search);
|
const searchParams = queryString.parse(useLocation().search);
|
||||||
const { page, sortcolumn, sortorder, search } = searchParams;
|
const { page, sortcolumn, sortorder, search, pageSize } = searchParams;
|
||||||
const history = useNavigate();
|
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, {
|
const { loading, error, data, refetch } = useQuery(QUERY_EXPORT_LOG_PAGINATED, {
|
||||||
fetchPolicy: "network-only",
|
fetchPolicy: "network-only",
|
||||||
nextFetchPolicy: "network-only",
|
nextFetchPolicy: "network-only",
|
||||||
variables: {
|
variables: {
|
||||||
search: search || "",
|
search: search || "",
|
||||||
offset: page ? (page - 1) * pageLimit : 0,
|
offset: (currentPage - 1) * currentPageSize,
|
||||||
limit: pageLimit,
|
limit: currentPageSize,
|
||||||
order: [
|
order: [
|
||||||
{
|
{
|
||||||
...(sortcolumn === "ro_number"
|
...(sortcolumn === "ro_number"
|
||||||
@@ -61,7 +65,11 @@ export function ExportLogsPageComponent() {
|
|||||||
if (error) return <AlertComponent title={error.message} type="error" />;
|
if (error) return <AlertComponent title={error.message} type="error" />;
|
||||||
|
|
||||||
const handleTableChange = (pagination, filters, sorter) => {
|
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.sortcolumn = sorter.columnKey;
|
||||||
searchParams.sortorder = sorter.order;
|
searchParams.sortorder = sorter.order;
|
||||||
if (filters.status) {
|
if (filters.status) {
|
||||||
@@ -191,8 +199,9 @@ export function ExportLogsPageComponent() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{
|
pagination={{
|
||||||
placement: "top",
|
placement: "top",
|
||||||
pageSize: pageLimit,
|
pageSize: currentPageSize,
|
||||||
current: parseInt(page || 1, 10),
|
current: currentPage,
|
||||||
|
showSizeChanger: true,
|
||||||
total: data && data.search_exportlog_aggregate.aggregate.count
|
total: data && data.search_exportlog_aggregate.aggregate.count
|
||||||
}}
|
}}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery } from "@apollo/client/react";
|
import { useMutation, useQuery } from "@apollo/client/react";
|
||||||
import { Button, Card, Col, Form, Input, Modal, Result, Row, Select, Space, Switch, Typography } from "antd";
|
import { Button, Card, Col, Form, Input, Modal, Result, Row, Select, Space, Switch, Typography } from "antd";
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
@@ -23,9 +23,8 @@ import LoadingSpinner from "../../components/loading-spinner/loading-spinner.com
|
|||||||
import NotFound from "../../components/not-found/not-found.component";
|
import NotFound from "../../components/not-found/not-found.component";
|
||||||
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
|
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
|
||||||
import RREarlyROModal from "../../components/dms-post-form/rr-early-ro-modal";
|
import RREarlyROModal from "../../components/dms-post-form/rr-early-ro-modal";
|
||||||
import { GET_JOB_BY_PK, CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
|
import { CONVERT_JOB_TO_RO, GET_JOB_BY_PK } from "../../graphql/jobs.queries";
|
||||||
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
|
import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
|
||||||
import { insertAuditTrail } from "../../redux/application/application.actions";
|
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket";
|
import { useSocket } from "../../contexts/SocketIO/useSocket";
|
||||||
@@ -302,7 +301,11 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Select>
|
<Select
|
||||||
|
showSearch={{
|
||||||
|
optionFilterProp: "children"
|
||||||
|
}}
|
||||||
|
>
|
||||||
{bodyshop?.md_referral_sources?.map((s) => (
|
{bodyshop?.md_referral_sources?.map((s) => (
|
||||||
<Select.Option key={s} value={s}>
|
<Select.Option key={s} value={s}>
|
||||||
{s}
|
{s}
|
||||||
@@ -379,7 +382,13 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Space wrap style={{ marginTop: 16 }}>
|
<Space wrap style={{ marginTop: 16 }}>
|
||||||
<Button disabled={submitDisabled()} type="primary" danger onClick={() => form.submit()} loading={convertLoading}>
|
<Button
|
||||||
|
disabled={submitDisabled()}
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
onClick={() => form.submit()}
|
||||||
|
loading={convertLoading}
|
||||||
|
>
|
||||||
{t("jobs.actions.convert")}
|
{t("jobs.actions.convert")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setShowConvertModal(false)}>{t("general.actions.close")}</Button>
|
<Button onClick={() => setShowConvertModal(false)}>{t("general.actions.close")}</Button>
|
||||||
|
|||||||
@@ -27,12 +27,19 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function TechAssignedProdJobs({ setTimeTicketTaskContext, technician, bodyshop }) {
|
export function TechAssignedProdJobs({ setTimeTicketTaskContext, technician, bodyshop }) {
|
||||||
|
const technicianId = technician?.id;
|
||||||
|
const teamIds = (bodyshop?.employee_teams || [])
|
||||||
|
.filter((employeeTeam) =>
|
||||||
|
employeeTeam?.employee_team_members?.some((teamMember) => teamMember?.employeeid === technicianId)
|
||||||
|
)
|
||||||
|
.map((employeeTeam) => employeeTeam.id)
|
||||||
|
.filter(Boolean);
|
||||||
|
const hasAssignedTeams = Boolean(technicianId) && teamIds.length > 0;
|
||||||
const { loading, error, data, refetch } = useQuery(QUERY_JOBS_TECH_ASIGNED_TO_BY_TEAM, {
|
const { loading, error, data, refetch } = useQuery(QUERY_JOBS_TECH_ASIGNED_TO_BY_TEAM, {
|
||||||
variables: {
|
variables: {
|
||||||
teamIds: bodyshop.employee_teams
|
teamIds
|
||||||
.filter((et) => et.employee_team_members.find((etm) => etm.employeeid === technician.id))
|
},
|
||||||
.map((et) => et.id)
|
skip: !technicianId || !hasAssignedTeams
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchParams = queryString.parse(useLocation().search);
|
const searchParams = queryString.parse(useLocation().search);
|
||||||
@@ -177,7 +184,7 @@ export function TechAssignedProdJobs({ setTimeTicketTaskContext, technician, bod
|
|||||||
<Card
|
<Card
|
||||||
extra={
|
extra={
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
|
<Button disabled={!hasAssignedTeams} onClick={() => refetch()} icon={<SyncOutlined />} />
|
||||||
<Input.Search
|
<Input.Search
|
||||||
placeholder={t("general.labels.search")}
|
placeholder={t("general.labels.search")}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|||||||
@@ -463,6 +463,7 @@
|
|||||||
"md_email_cc": "Auto Email CC: $t(parts_orders.labels.{{template}})",
|
"md_email_cc": "Auto Email CC: $t(parts_orders.labels.{{template}})",
|
||||||
"md_from_emails": "Additional From Emails",
|
"md_from_emails": "Additional From Emails",
|
||||||
"md_functionality_toggles": {
|
"md_functionality_toggles": {
|
||||||
|
"enhanced_early_ros": "Enable Enhance Early ROs",
|
||||||
"parts_queue_toggle": "Auto Add Imported/Supplemented Jobs to Parts Queue"
|
"parts_queue_toggle": "Auto Add Imported/Supplemented Jobs to Parts Queue"
|
||||||
},
|
},
|
||||||
"md_hour_split": {
|
"md_hour_split": {
|
||||||
@@ -1221,6 +1222,7 @@
|
|||||||
"confirmdelete": "Are you sure you want to delete these documents. This CANNOT be undone.",
|
"confirmdelete": "Are you sure you want to delete these documents. This CANNOT be undone.",
|
||||||
"doctype": "Document Type",
|
"doctype": "Document Type",
|
||||||
"dragtoupload": "Click or drag files to this area to upload",
|
"dragtoupload": "Click or drag files to this area to upload",
|
||||||
|
"greyscale": "Greyscale",
|
||||||
"newjobid": "Assign to Job",
|
"newjobid": "Assign to Job",
|
||||||
"openinexplorer": "Open in Explorer",
|
"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.",
|
"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,9 +1787,9 @@
|
|||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"addpayer": "Add Payer",
|
|
||||||
"addDocuments": "Add Job Documents",
|
"addDocuments": "Add Job Documents",
|
||||||
"addNote": "Add Note",
|
"addNote": "Add Note",
|
||||||
|
"addpayer": "Add Payer",
|
||||||
"addtopartsqueue": "Add to Parts Queue",
|
"addtopartsqueue": "Add to Parts Queue",
|
||||||
"addtoproduction": "Add to Production",
|
"addtoproduction": "Add to Production",
|
||||||
"addtoscoreboard": "Add to Scoreboard",
|
"addtoscoreboard": "Add to Scoreboard",
|
||||||
@@ -1964,6 +1966,7 @@
|
|||||||
"ded_status": "Deductible Status",
|
"ded_status": "Deductible Status",
|
||||||
"depreciation_taxes": "Betterment/Depreciation/Taxes",
|
"depreciation_taxes": "Betterment/Depreciation/Taxes",
|
||||||
"dms": {
|
"dms": {
|
||||||
|
"IsARCustomer": "AR Customer?",
|
||||||
"address": "Customer Address",
|
"address": "Customer Address",
|
||||||
"advisor": "Advisor #",
|
"advisor": "Advisor #",
|
||||||
"amount": "Amount",
|
"amount": "Amount",
|
||||||
@@ -3265,6 +3268,7 @@
|
|||||||
"information": "Information",
|
"information": "Information",
|
||||||
"layout": "Layout",
|
"layout": "Layout",
|
||||||
"statistics": {
|
"statistics": {
|
||||||
|
"exclude_suspended": "Exclude Suspended Jobs",
|
||||||
"jobs_in_production": "Jobs in Production",
|
"jobs_in_production": "Jobs in Production",
|
||||||
"tasks_in_production": "Tasks in Production",
|
"tasks_in_production": "Tasks in Production",
|
||||||
"tasks_in_view": "Tasks in View",
|
"tasks_in_view": "Tasks in View",
|
||||||
|
|||||||
@@ -457,6 +457,7 @@
|
|||||||
"md_email_cc": "",
|
"md_email_cc": "",
|
||||||
"md_from_emails": "",
|
"md_from_emails": "",
|
||||||
"md_functionality_toggles": {
|
"md_functionality_toggles": {
|
||||||
|
"enhanced_early_ros": "",
|
||||||
"parts_queue_toggle": ""
|
"parts_queue_toggle": ""
|
||||||
},
|
},
|
||||||
"md_hour_split": {
|
"md_hour_split": {
|
||||||
@@ -1215,6 +1216,7 @@
|
|||||||
"confirmdelete": "",
|
"confirmdelete": "",
|
||||||
"doctype": "",
|
"doctype": "",
|
||||||
"dragtoupload": "",
|
"dragtoupload": "",
|
||||||
|
"greyscale": "Escala de grises",
|
||||||
"newjobid": "",
|
"newjobid": "",
|
||||||
"openinexplorer": "",
|
"openinexplorer": "",
|
||||||
"optimizedimage": "",
|
"optimizedimage": "",
|
||||||
@@ -1779,9 +1781,9 @@
|
|||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"addpayer": "",
|
|
||||||
"addDocuments": "Agregar documentos de trabajo",
|
"addDocuments": "Agregar documentos de trabajo",
|
||||||
"addNote": "Añadir la nota",
|
"addNote": "Añadir la nota",
|
||||||
|
"addpayer": "",
|
||||||
"addtopartsqueue": "",
|
"addtopartsqueue": "",
|
||||||
"addtoproduction": "",
|
"addtoproduction": "",
|
||||||
"addtoscoreboard": "",
|
"addtoscoreboard": "",
|
||||||
@@ -1958,6 +1960,7 @@
|
|||||||
"ded_status": "Estado deducible",
|
"ded_status": "Estado deducible",
|
||||||
"depreciation_taxes": "Depreciación / Impuestos",
|
"depreciation_taxes": "Depreciación / Impuestos",
|
||||||
"dms": {
|
"dms": {
|
||||||
|
"IsARCustomer": "",
|
||||||
"address": "",
|
"address": "",
|
||||||
"advisor": "",
|
"advisor": "",
|
||||||
"amount": "",
|
"amount": "",
|
||||||
@@ -3259,6 +3262,7 @@
|
|||||||
"information": "",
|
"information": "",
|
||||||
"layout": "",
|
"layout": "",
|
||||||
"statistics": {
|
"statistics": {
|
||||||
|
"exclude_suspended": "",
|
||||||
"jobs_in_production": "",
|
"jobs_in_production": "",
|
||||||
"tasks_in_production": "",
|
"tasks_in_production": "",
|
||||||
"tasks_in_view": "",
|
"tasks_in_view": "",
|
||||||
|
|||||||
@@ -457,6 +457,7 @@
|
|||||||
"md_email_cc": "",
|
"md_email_cc": "",
|
||||||
"md_from_emails": "",
|
"md_from_emails": "",
|
||||||
"md_functionality_toggles": {
|
"md_functionality_toggles": {
|
||||||
|
"enhanced_early_ros": "",
|
||||||
"parts_queue_toggle": ""
|
"parts_queue_toggle": ""
|
||||||
},
|
},
|
||||||
"md_hour_split": {
|
"md_hour_split": {
|
||||||
@@ -1215,6 +1216,7 @@
|
|||||||
"confirmdelete": "",
|
"confirmdelete": "",
|
||||||
"doctype": "",
|
"doctype": "",
|
||||||
"dragtoupload": "",
|
"dragtoupload": "",
|
||||||
|
"greyscale": "Niveaux de gris",
|
||||||
"newjobid": "",
|
"newjobid": "",
|
||||||
"openinexplorer": "",
|
"openinexplorer": "",
|
||||||
"optimizedimage": "",
|
"optimizedimage": "",
|
||||||
@@ -1779,9 +1781,9 @@
|
|||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"addpayer": "",
|
|
||||||
"addDocuments": "Ajouter des documents de travail",
|
"addDocuments": "Ajouter des documents de travail",
|
||||||
"addNote": "Ajouter une note",
|
"addNote": "Ajouter une note",
|
||||||
|
"addpayer": "",
|
||||||
"addtopartsqueue": "",
|
"addtopartsqueue": "",
|
||||||
"addtoproduction": "",
|
"addtoproduction": "",
|
||||||
"addtoscoreboard": "",
|
"addtoscoreboard": "",
|
||||||
@@ -1958,6 +1960,7 @@
|
|||||||
"ded_status": "Statut de franchise",
|
"ded_status": "Statut de franchise",
|
||||||
"depreciation_taxes": "Amortissement / taxes",
|
"depreciation_taxes": "Amortissement / taxes",
|
||||||
"dms": {
|
"dms": {
|
||||||
|
"IsARCustomer": "",
|
||||||
"address": "",
|
"address": "",
|
||||||
"advisor": "",
|
"advisor": "",
|
||||||
"amount": "",
|
"amount": "",
|
||||||
@@ -3259,6 +3262,7 @@
|
|||||||
"information": "",
|
"information": "",
|
||||||
"layout": "",
|
"layout": "",
|
||||||
"statistics": {
|
"statistics": {
|
||||||
|
"exclude_suspended": "",
|
||||||
"jobs_in_production": "",
|
"jobs_in_production": "",
|
||||||
"tasks_in_production": "",
|
"tasks_in_production": "",
|
||||||
"tasks_in_view": "",
|
"tasks_in_view": "",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
//import { store } from "../redux/store";
|
//import { store } from "../redux/store";
|
||||||
|
import { DMS_MAP } from "./dmsUtils";
|
||||||
import InstanceRenderManager from "./instanceRenderMgr";
|
import InstanceRenderManager from "./instanceRenderMgr";
|
||||||
|
|
||||||
export const EmailSettings = {
|
export const EmailSettings = {
|
||||||
@@ -570,7 +571,8 @@ export const TemplateList = (type, context) => {
|
|||||||
key: "dms_posting_sheet",
|
key: "dms_posting_sheet",
|
||||||
disabled: false,
|
disabled: false,
|
||||||
group: "financial",
|
group: "financial",
|
||||||
dms: true
|
dms: true,
|
||||||
|
excludedDmsModes: [DMS_MAP.reynolds]
|
||||||
},
|
},
|
||||||
worksheet_sorted_by_team: {
|
worksheet_sorted_by_team: {
|
||||||
title: i18n.t("printcenter.jobs.worksheet_sorted_by_team"),
|
title: i18n.t("printcenter.jobs.worksheet_sorted_by_team"),
|
||||||
|
|||||||
@@ -98,12 +98,26 @@ exports.PbsSelectedCustomer = async function PbsSelectedCustomer(socket, selecte
|
|||||||
socket.JobData.ownr_fn || ""
|
socket.JobData.ownr_fn || ""
|
||||||
} ${socket.JobData.ownr_ln || ""} ${socket.JobData.ownr_co_nm || ""}`
|
} ${socket.JobData.ownr_ln || ""} ${socket.JobData.ownr_co_nm || ""}`
|
||||||
);
|
);
|
||||||
const ownerRef = await UpsertContactData(socket, selectedCustomerId);
|
|
||||||
socket.ownerRef = ownerRef;
|
|
||||||
|
|
||||||
WsLogger.createLogEvent(socket, "INFO", `Upserting vehicle information to DMS for ${socket.JobData.v_vin}`);
|
//If this is an AR customer, don't do anything.
|
||||||
const vehicleRef = await UpsertVehicleData(socket, ownerRef.ReferenceId);
|
|
||||||
socket.vehicleRef = vehicleRef;
|
const selectedCustomer = [...(socket.DMSVehCustomer ? [{ ...socket.DMSVehCustomer, vinOwner: true }] : []),
|
||||||
|
...socket.DMSCustList]?.find((cust) => cust.ContactId === selectedCustomerId);
|
||||||
|
|
||||||
|
if (selectedCustomer?.IsARCustomer) {
|
||||||
|
|
||||||
|
WsLogger.createLogEvent(socket, "INFO", `Skipping contact and vehicle update becuase it is marked as an AR contact in PBS.`);
|
||||||
|
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
|
||||||
|
const ownerRef = await UpsertContactData(socket, selectedCustomerId);
|
||||||
|
socket.ownerRef = ownerRef;
|
||||||
|
WsLogger.createLogEvent(socket, "INFO", `Upserting vehicle information to DMS for ${socket.JobData.v_vin}`);
|
||||||
|
const vehicleRef = await UpsertVehicleData(socket, ownerRef.ReferenceId);
|
||||||
|
socket.vehicleRef = vehicleRef;
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
WsLogger.createLogEvent(
|
WsLogger.createLogEvent(
|
||||||
socket,
|
socket,
|
||||||
|
|||||||
@@ -88,6 +88,15 @@ describe("server/rr/rr-export-logs", () => {
|
|||||||
statusCode: "0",
|
statusCode: "0",
|
||||||
message: "Finalized"
|
message: "Finalized"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
metaExtra: {
|
||||||
|
rrPreview: {
|
||||||
|
provider: "rr",
|
||||||
|
previewFormat: "rr-rogog-preview.v1",
|
||||||
|
rogg: {
|
||||||
|
rows: [{ jobNo: "1", opCode: "BODY", custPrice: "125.00", dlrCost: "50.00" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,7 +115,17 @@ describe("server/rr/rr-export-logs", () => {
|
|||||||
bodyshopid: "bodyshop-1",
|
bodyshopid: "bodyshop-1",
|
||||||
jobid: "job-1",
|
jobid: "job-1",
|
||||||
successful: true,
|
successful: true,
|
||||||
useremail: "tech@example.com"
|
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: {
|
bill: {
|
||||||
exported: true,
|
exported: true,
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ const { buildRRRepairOrderPayload, buildMinimalRolaborFromJob } = require("./rr-
|
|||||||
const { buildClientAndOpts } = require("./rr-lookup");
|
const { buildClientAndOpts } = require("./rr-lookup");
|
||||||
const CreateRRLogEvent = require("./rr-logger-event");
|
const CreateRRLogEvent = require("./rr-logger-event");
|
||||||
const { withRRRequestXml } = require("./rr-log-xml");
|
const { withRRRequestXml } = require("./rr-log-xml");
|
||||||
|
const { buildRRPreviewMetadata } = require("./rr-preview-metadata");
|
||||||
const { extractRrResponsibilityCenters } = require("./rr-responsibility-centers");
|
const { extractRrResponsibilityCenters } = require("./rr-responsibility-centers");
|
||||||
const CdkCalculateAllocations = require("./rr-calculate-allocations").default;
|
const CdkCalculateAllocations = require("./rr-calculate-allocations").default;
|
||||||
const { resolveRROpCodeFromBodyshop } = require("./rr-utils");
|
const { isEnhancedEarlyROEnabled, resolveRROpCodeFromBodyshop } = require("./rr-utils");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive RR status information from response object.
|
* Derive RR status information from response object.
|
||||||
@@ -139,11 +140,14 @@ const createMinimalRRRepairOrder = async (args) => {
|
|||||||
resolvedMileageIn: mileageIn
|
resolvedMileageIn: mileageIn
|
||||||
});
|
});
|
||||||
|
|
||||||
const earlyRoOpCode = resolveRROpCode(bodyshop, txEnvelope);
|
const enhancedEarlyROEnabled = isEnhancedEarlyROEnabled(bodyshop);
|
||||||
const earlyRoLabor = buildMinimalRolaborFromJob(job, {
|
const earlyRoOpCode = enhancedEarlyROEnabled ? resolveRROpCode(bodyshop, txEnvelope) : null;
|
||||||
opCode: earlyRoOpCode,
|
const earlyRoLabor = enhancedEarlyROEnabled
|
||||||
payType: "Cust"
|
? buildMinimalRolaborFromJob(job, {
|
||||||
});
|
opCode: earlyRoOpCode,
|
||||||
|
payType: "Cust"
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
customerNo: String(selected),
|
customerNo: String(selected),
|
||||||
@@ -176,13 +180,19 @@ const createMinimalRRRepairOrder = async (args) => {
|
|||||||
|
|
||||||
CreateRRLogEvent(socket, "INFO", "Creating minimal RR Repair Order (early creation)", {
|
CreateRRLogEvent(socket, "INFO", "Creating minimal RR Repair Order (early creation)", {
|
||||||
payload,
|
payload,
|
||||||
|
enhancedEarlyROEnabled,
|
||||||
earlyRoOpCode,
|
earlyRoOpCode,
|
||||||
hasRolabor: !!earlyRoLabor
|
hasRolabor: !!earlyRoLabor
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await client.createRepairOrder(payload, finalOpts);
|
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 data = response?.data || null;
|
||||||
const statusBlocks = response?.statusBlocks || {};
|
const statusBlocks = response?.statusBlocks || {};
|
||||||
@@ -331,16 +341,12 @@ const updateRRRepairOrderWithFullData = async (args) => {
|
|||||||
payload.roNo = String(roNo);
|
payload.roNo = String(roNo);
|
||||||
payload.outsdRoNo = job?.ro_number || job?.id || undefined;
|
payload.outsdRoNo = job?.ro_number || job?.id || undefined;
|
||||||
|
|
||||||
// RR update rejects placeholder non-labor ROLABOR rows with zero labor prices.
|
// RR update needs a ROLABOR row for every ROGOG JobNo, but rejects zero-price placeholders.
|
||||||
// Keep only the actual labor jobs in ROLABOR and let ROGOG carry parts/extras.
|
// buildRolaborFromRogog mirrors the GOG price into each row, so keep the full 1:1 set.
|
||||||
if (payload.rolabor?.ops?.length && payload.rogg?.ops?.length) {
|
if (payload.rolabor?.ops?.length && payload.rogg?.ops?.length) {
|
||||||
const laborJobNos = new Set(
|
const roggJobNos = new Set(payload.rogg.ops.map((op) => String(op.jobNo)));
|
||||||
payload.rogg.ops
|
|
||||||
.filter((op) => op?.segmentKind === "laborTaxable" || op?.segmentKind === "laborNonTaxable")
|
|
||||||
.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) {
|
if (!payload.rolabor.ops.length) {
|
||||||
delete payload.rolabor;
|
delete payload.rolabor;
|
||||||
@@ -387,7 +393,7 @@ const updateRRRepairOrderWithFullData = async (args) => {
|
|||||||
success = String(roStatus.status).toUpperCase() === "SUCCESS";
|
success = String(roStatus.status).toUpperCase() === "SUCCESS";
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const exportResult = {
|
||||||
success,
|
success,
|
||||||
data,
|
data,
|
||||||
roStatus,
|
roStatus,
|
||||||
@@ -397,6 +403,8 @@ const updateRRRepairOrderWithFullData = async (args) => {
|
|||||||
roNo: String(roNo),
|
roNo: String(roNo),
|
||||||
xml: response?.xml
|
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
|
// Extract canonical roNo you'll need for finalize step
|
||||||
const roNo = data?.dmsRoNo ?? data?.outsdRoNo ?? roStatus?.dmsRoNo ?? null;
|
const roNo = data?.dmsRoNo ?? data?.outsdRoNo ?? roStatus?.dmsRoNo ?? null;
|
||||||
|
|
||||||
return {
|
const exportResult = {
|
||||||
success,
|
success,
|
||||||
data,
|
data,
|
||||||
roStatus,
|
roStatus,
|
||||||
@@ -538,6 +546,8 @@ const exportJobToRR = async (args) => {
|
|||||||
roNo,
|
roNo,
|
||||||
xml: response?.xml // expose XML for logging/diagnostics
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -120,6 +120,22 @@ const formatDecimal = (value, maxDecimals = 2) => {
|
|||||||
return rounded.toFixed(maxDecimals).replace(/\.?0+$/, "") || "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 buildRolaborBillFields = ({ amountUnits = 0, hours = 0, rate = 0 } = {}) => {
|
||||||
const normalizedAmount = toFiniteNumber(amountUnits);
|
const normalizedAmount = toFiniteNumber(amountUnits);
|
||||||
|
|
||||||
@@ -335,6 +351,7 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
|
|||||||
const pc = alloc?.profitCenter || {};
|
const pc = alloc?.profitCenter || {};
|
||||||
const breakOut = pc.rr_gogcode;
|
const breakOut = pc.rr_gogcode;
|
||||||
const itemType = pc.rr_item_type;
|
const itemType = pc.rr_item_type;
|
||||||
|
const laborSideProfitCenter = isLaborSideProfitCenter(alloc);
|
||||||
|
|
||||||
// Only centers configured for RR GOG are included
|
// Only centers configured for RR GOG are included
|
||||||
if (!breakOut || !itemType) continue;
|
if (!breakOut || !itemType) continue;
|
||||||
@@ -434,6 +451,8 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
|
|||||||
segments.forEach((seg, idx) => {
|
segments.forEach((seg, idx) => {
|
||||||
const jobNo = String(ops.length + 1); // global, 1-based JobNo across all centers/segments
|
const jobNo = String(ops.length + 1); // global, 1-based JobNo across all centers/segments
|
||||||
const isLaborSegment = seg.kind === "laborTaxable" || seg.kind === "laborNonTaxable";
|
const isLaborSegment = seg.kind === "laborTaxable" || seg.kind === "laborNonTaxable";
|
||||||
|
const isPartsSegment = seg.kind === "partsTaxable" || seg.kind === "partsNonTaxable";
|
||||||
|
const rolaborRequired = isLaborSegment || (isPartsSegment && laborSideProfitCenter);
|
||||||
const segmentHours = isLaborSegment
|
const segmentHours = isLaborSegment
|
||||||
? seg.kind === "laborTaxable"
|
? seg.kind === "laborTaxable"
|
||||||
? toFiniteNumber(alloc.laborTaxableHours)
|
? toFiniteNumber(alloc.laborTaxableHours)
|
||||||
@@ -465,7 +484,8 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
|
|||||||
segmentIndex: idx,
|
segmentIndex: idx,
|
||||||
segmentCount,
|
segmentCount,
|
||||||
segmentHours,
|
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
|
* 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
|
* 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
|
* GOG line. Sale amounts are mirrored into ROLABOR so Reynolds has a
|
||||||
* are available from allocations, weighted bill hours/rates are also
|
* non-zero job anchor for every ROGOG JobNo; when labor hours are available
|
||||||
* populated so the labor subsection is editable in Ignite.
|
* from allocations, weighted bill hours/rates are also populated.
|
||||||
*
|
*
|
||||||
* @param {Object} rogg - result of buildRogogFromAllocations
|
* @param {Object} rogg - result of buildRogogFromAllocations
|
||||||
* @param {Object} opts
|
* @param {Object} opts
|
||||||
@@ -506,7 +526,7 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => {
|
|||||||
|
|
||||||
const linePayType = firstLine.custPayTypeFlag || "C";
|
const linePayType = firstLine.custPayTypeFlag || "C";
|
||||||
const isLaborSegment = op.segmentKind === "laborTaxable" || op.segmentKind === "laborNonTaxable";
|
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
|
const laborBill = isLaborSegment
|
||||||
? buildRolaborBillFields({
|
? buildRolaborBillFields({
|
||||||
amountUnits: laborAmount,
|
amountUnits: laborAmount,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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,
|
dmsRoNo,
|
||||||
customerNo: String(effectiveCustNo),
|
customerNo: String(effectiveCustNo),
|
||||||
advisorNo: String(advisorNo),
|
advisorNo: String(advisorNo),
|
||||||
vin: job?.v_vin || null
|
vin: job?.v_vin || null,
|
||||||
|
rrPreview: result?.rrPreview || null
|
||||||
},
|
},
|
||||||
defaultRRTTL
|
defaultRRTTL
|
||||||
);
|
);
|
||||||
@@ -1705,7 +1706,10 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
|||||||
jobId: rid,
|
jobId: rid,
|
||||||
job,
|
job,
|
||||||
bodyshop,
|
bodyshop,
|
||||||
result: finalizeResult
|
result: finalizeResult,
|
||||||
|
metaExtra: {
|
||||||
|
rrPreview: pending?.rrPreview || null
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean pending key
|
// Clean pending key
|
||||||
|
|||||||
@@ -205,10 +205,19 @@ const resolveRROpCodeFromBodyshop = (bodyshop) => {
|
|||||||
return `${prefix}${base}${suffix}`;
|
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 = {
|
module.exports = {
|
||||||
RRCacheEnums,
|
RRCacheEnums,
|
||||||
defaultRRTTL,
|
defaultRRTTL,
|
||||||
getTransactionType,
|
getTransactionType,
|
||||||
|
isEnhancedEarlyROEnabled,
|
||||||
ownersFromVinBlocks,
|
ownersFromVinBlocks,
|
||||||
makeVehicleSearchPayloadFromJob,
|
makeVehicleSearchPayloadFromJob,
|
||||||
normalizeCustomerCandidates,
|
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