diff --git a/bodyshop_translations.babel b/bodyshop_translations.babel index 2449aeb52..e31ec91f4 100644 --- a/bodyshop_translations.babel +++ b/bodyshop_translations.babel @@ -27699,6 +27699,27 @@ + + addpayer + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + addtopartsqueue false @@ -31118,6 +31139,27 @@ dms + + IsARCustomer + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + address false @@ -45333,6 +45375,69 @@ + + esign-document-completed + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + esign-document-opened + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + esign-document-upload-failed + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + intake-delivery-checklist-completed false diff --git a/client/src/components/dms-customer-selector/pbs-customer-selector.jsx b/client/src/components/dms-customer-selector/pbs-customer-selector.jsx index fcded201e..7389e2f2d 100644 --- a/client/src/components/dms-customer-selector/pbs-customer-selector.jsx +++ b/client/src/components/dms-customer-selector/pbs-customer-selector.jsx @@ -1,4 +1,4 @@ -import { Button, Col } from "antd"; +import { Button, Checkbox, Col } from "antd"; import ResponsiveTable from "../responsive-table/responsive-table.component"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -49,7 +49,13 @@ export default function PBSCustomerSelector({ bodyshop, socket }) { if (!open) return null; 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) => + }, { title: t("jobs.fields.dms.name1"), key: "name1", diff --git a/client/src/components/document-editor/document-editor-local.component.jsx b/client/src/components/document-editor/document-editor-local.component.jsx index 18bf0ead8..724caea34 100644 --- a/client/src/components/document-editor/document-editor-local.component.jsx +++ b/client/src/components/document-editor/document-editor-local.component.jsx @@ -1,5 +1,5 @@ import axios from "axios"; -import { Result } from "antd"; +import { Result, theme } from "antd"; import * as markerjs2 from "markerjs2"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -9,6 +9,12 @@ import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selecto import { handleUpload } from "../documents-local-upload/documents-local-upload.utility"; import LoadingSpinner from "../loading-spinner/loading-spinner.component"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import { + addGreyscaleButtonToMarkerArea, + addImageHistoryUndoToMarkerArea, + applyGreyscaleToMarkerAreaImage, + setMarkerAreaImageSource +} from "./document-editor.utility"; const mapStateToProps = createStructuredSelector({ currentUser: selectCurrentUser, @@ -24,7 +30,9 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { const [imageLoaded, setImageLoaded] = useState(false); const [imageLoading, setImageLoading] = useState(true); const markerArea = useRef(null); + const imageHistory = useRef([]); const { t } = useTranslation(); + const { token } = theme.useToken(); const notification = useNotification(); const [uploading, setUploading] = useState(false); @@ -32,6 +40,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { async (dataUrl) => { if (uploading) return; setUploading(true); + setLoading(true); const blob = await b64toBlob(dataUrl); const nameWithoutExt = filename.split(".").slice(0, -1).join(".").trim(); const parts = nameWithoutExt.split("-"); @@ -70,6 +79,23 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { [filename, jobid, notification, uploading] ); + const handleGreyscale = useCallback(() => { + if (!imgRef.current || loading || uploaded || imageLoading || !imageLoaded) return; + + imageHistory.current.push(imgRef.current.src); + applyGreyscaleToMarkerAreaImage(markerArea.current, imgRef.current); + }, [imageLoaded, imageLoading, loading, uploaded]); + + const undoImageEdit = useCallback(() => { + if (!imgRef.current) return; + + const previousSrc = imageHistory.current.pop(); + + if (previousSrc) { + setMarkerAreaImageSource(markerArea.current, imgRef.current, previousSrc); + } + }, []); + useEffect(() => { if (imgRef.current !== null && imageLoaded && !markerArea.current) { // create a marker.js MarkerArea @@ -93,8 +119,10 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { markerArea.current.renderImageQuality = 1; //markerArea.current.settings.displayMode = "inline"; markerArea.current.show(); + addGreyscaleButtonToMarkerArea(markerArea.current, handleGreyscale, t("documents.labels.greyscale")); + addImageHistoryUndoToMarkerArea(markerArea.current, () => imageHistory.current.length > 0, undoImageEdit); } - }, [triggerUpload, imageLoaded]); + }, [handleGreyscale, imageLoaded, t, triggerUpload, undoImageEdit]); useEffect(() => { if (!imageUrl) return; @@ -106,6 +134,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { try { const response = await axios.get(imageUrl, { responseType: "blob", signal: controller.signal }); const blobUrl = URL.createObjectURL(response.data); + imageHistory.current = []; setLoadedImageUrl((prevUrl) => { if (prevUrl) URL.revokeObjectURL(prevUrl); return blobUrl; @@ -142,7 +171,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { } return ( -
+
{!loading && !uploaded && loadedImageUrl && ( )} - {uploaded && } + {uploaded && ( + {t("documents.successes.edituploaded")}} + /> + )}
); } diff --git a/client/src/components/document-editor/document-editor.component.jsx b/client/src/components/document-editor/document-editor.component.jsx index 1bdf0f9e7..d9b7f8758 100644 --- a/client/src/components/document-editor/document-editor.component.jsx +++ b/client/src/components/document-editor/document-editor.component.jsx @@ -1,15 +1,21 @@ //import "tui-image-editor/dist/tui-image-editor.css"; import axios from "axios"; -import { Result } from "antd"; +import { Result, theme } from "antd"; import * as markerjs2 from "markerjs2"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; +import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { handleUpload } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility.js"; import LoadingSpinner from "../loading-spinner/loading-spinner.component"; -import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import { + addGreyscaleButtonToMarkerArea, + addImageHistoryUndoToMarkerArea, + applyGreyscaleToMarkerAreaImage, + setMarkerAreaImageSource +} from "./document-editor.utility"; const mapStateToProps = createStructuredSelector({ currentUser: selectCurrentUser, @@ -27,7 +33,9 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { const [imageLoaded, setImageLoaded] = useState(false); const [imageLoading, setImageLoading] = useState(true); const markerArea = useRef(null); + const imageHistory = useRef([]); const { t } = useTranslation(); + const { token } = theme.useToken(); const notification = useNotification(); const triggerUpload = useCallback( @@ -57,6 +65,23 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { [bodyshop, currentUser, document, notification] ); + const handleGreyscale = useCallback(() => { + if (!imgRef.current || loading || uploaded || imageLoading || !imageLoaded) return; + + imageHistory.current.push(imgRef.current.src); + applyGreyscaleToMarkerAreaImage(markerArea.current, imgRef.current); + }, [imageLoaded, imageLoading, loading, uploaded]); + + const undoImageEdit = useCallback(() => { + if (!imgRef.current) return; + + const previousSrc = imageHistory.current.pop(); + + if (previousSrc) { + setMarkerAreaImageSource(markerArea.current, imgRef.current, previousSrc); + } + }, []); + useEffect(() => { if (imgRef.current !== null && imageLoaded && !markerArea.current) { // create a marker.js MarkerArea @@ -80,8 +105,10 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { markerArea.current.renderImageQuality = 1; //markerArea.current.settings.displayMode = "inline"; markerArea.current.show(); + addGreyscaleButtonToMarkerArea(markerArea.current, handleGreyscale, t("documents.labels.greyscale")); + addImageHistoryUndoToMarkerArea(markerArea.current, () => imageHistory.current.length > 0, undoImageEdit); } - }, [triggerUpload, imageLoaded]); + }, [handleGreyscale, imageLoaded, t, triggerUpload, undoImageEdit]); useEffect(() => { if (!document?.id) return; @@ -100,6 +127,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { } ); const blobUrl = URL.createObjectURL(response.data); + imageHistory.current = []; setImageUrl((prevUrl) => { if (prevUrl) URL.revokeObjectURL(prevUrl); return blobUrl; @@ -134,7 +162,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { } return ( -
+
{!loading && !uploaded && imageUrl && ( )} - {uploaded && } + {uploaded && ( + {t("documents.successes.edituploaded")}} + /> + )}
); } diff --git a/client/src/components/document-editor/document-editor.utility.js b/client/src/components/document-editor/document-editor.utility.js new file mode 100644 index 000000000..9efceec62 --- /dev/null +++ b/client/src/components/document-editor/document-editor.utility.js @@ -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 = + ''; + 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; + }); + }); +} diff --git a/client/src/components/job-detail-lines/job-lines.component.jsx b/client/src/components/job-detail-lines/job-lines.component.jsx index 16a3d6c87..7e3ce8b85 100644 --- a/client/src/components/job-detail-lines/job-lines.component.jsx +++ b/client/src/components/job-detail-lines/job-lines.component.jsx @@ -44,6 +44,7 @@ import JoblineTeamAssignment from "../job-line-team-assignment/job-line-team-ass import JobSendPartPriceChangeComponent from "../job-send-parts-price-change/job-send-parts-price-change.component"; import PartsOrderDrawer from "../parts-order-list-table/parts-order-list-table-drawer.component"; import PartsOrderModalContainer from "../parts-order-modal/parts-order-modal.container"; +import { buildInHouseBillLines } from "./job-lines.in-house-bill-lines.utils"; import JobLinesExpander from "./job-lines-expander.component"; import JobLinesPartPriceChange from "./job-lines-part-price-change.component"; import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component"; @@ -595,16 +596,7 @@ export function JobLinesComponent({ isinhouse: true, date: dayjs(), total: 0, - billlines: selectedLines.map((p) => ({ - joblineid: p.id, - actual_price: p.act_price, - actual_cost: 0, - line_desc: p.line_desc, - line_remarks: p.line_remarks, - part_type: p.part_type, - quantity: p.quantity || 1, - applicable_taxes: { local: false, state: false, federal: false } - })) + billlines: buildInHouseBillLines(selectedLines) } } }); diff --git a/client/src/components/job-detail-lines/job-lines.in-house-bill-lines.utils.js b/client/src/components/job-detail-lines/job-lines.in-house-bill-lines.utils.js new file mode 100644 index 000000000..8a75d1755 --- /dev/null +++ b/client/src/components/job-detail-lines/job-lines.in-house-bill-lines.utils.js @@ -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 } + })); diff --git a/client/src/components/job-detail-lines/job-lines.in-house-bill-lines.utils.test.js b/client/src/components/job-detail-lines/job-lines.in-house-bill-lines.utils.test.js new file mode 100644 index 000000000..27f5f731b --- /dev/null +++ b/client/src/components/job-detail-lines/job-lines.in-house-bill-lines.utils.test.js @@ -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); + }); +}); diff --git a/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx b/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx index 78ebecf27..8d83ff6e8 100644 --- a/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx +++ b/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx @@ -224,14 +224,10 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr )} - + + - (option?.label ?? "").toLowerCase().includes(input.toLowerCase()) + optionFilterProp: "label", + filterOption: (input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase()) }} style={{ width: 200 }} - options={csrOptions} /> )} {bodyshop.enforce_conversion_category && ( - + ({ value: s, label: s diff --git a/client/src/components/jobs-detail-general/jobs-detail-general.component.jsx b/client/src/components/jobs-detail-general/jobs-detail-general.component.jsx index 80a3dd537..31c2295d5 100644 --- a/client/src/components/jobs-detail-general/jobs-detail-general.component.jsx +++ b/client/src/components/jobs-detail-general/jobs-detail-general.component.jsx @@ -43,19 +43,25 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) { - - ({ + value: n, + label: n + }))} + /> @@ -65,10 +71,14 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) { - ({ + value: s.name, + label: s.name + }))} + /> @@ -119,19 +129,30 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) { } ]} > - ({ + value: s, + label: s + }))} + /> - ({ + value: s, + label: s + }))} + /> @@ -233,10 +254,14 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) { - ({ + value: s, + label: s + }))} + /> diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx index 8f78cf675..d34e0645e 100644 --- a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx +++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx @@ -3,7 +3,7 @@ import { Button, Card, Col, Row, Space } from "antd"; import axios from "axios"; import i18n from "i18next"; import { isFunction } from "lodash"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import Lightbox from "react-image-lightbox"; import "react-image-lightbox/style.css"; @@ -12,12 +12,12 @@ import { createStructuredSelector } from "reselect"; import { selectBodyshop } from "../../redux/user/user.selectors"; import DocumentsUploadImgproxyComponent from "../documents-upload-imgproxy/documents-upload-imgproxy.component"; import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; +import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component"; import UpsellComponent, { upsellEnum } from "../upsell/upsell.component"; import JobsDocumentsDownloadButton from "./jobs-document-imgproxy-gallery.download.component"; import JobsDocumentsGalleryReassign from "./jobs-document-imgproxy-gallery.reassign.component"; import JobsDocumentsDeleteButton from "./jobs-documents-imgproxy-gallery.delete.component"; import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-imgproxy-gallery.selectall.component"; -import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -38,6 +38,9 @@ function JobsDocumentsImgproxyComponent({ const [galleryImages, setGalleryImages] = useState({ images: [], other: [] }); const { t } = useTranslation(); const [modalState, setModalState] = useState({ open: false, index: 0 }); + const [previewUrls, setPreviewUrls] = useState({}); + const [previewError, setPreviewError] = useState(null); + const previewUrlsRef = useRef({}); const fetchThumbnails = useCallback(() => { fetchImgproxyThumbnails({ setStateCallback: setGalleryImages, jobId, billId }); @@ -49,8 +52,86 @@ function JobsDocumentsImgproxyComponent({ } }, [data, fetchThumbnails]); + useEffect(() => { + return () => { + Object.values(previewUrlsRef.current).forEach(URL.revokeObjectURL); + }; + }, []); + + const selectedImage = modalState.open ? galleryImages.images[modalState.index] : null; + + useEffect(() => { + if (!modalState.open || !selectedImage?.id) return; + + if (previewUrlsRef.current[selectedImage.id]) { + setPreviewError(null); + return; + } + + const controller = new AbortController(); + + async function loadPreviewImage() { + setPreviewError(null); + + try { + const response = await axios.post( + "/media/imgproxy/original", + { documentId: selectedImage.id }, + { + responseType: "blob", + signal: controller.signal + } + ); + const blobUrl = URL.createObjectURL(response.data); + + previewUrlsRef.current = { + ...previewUrlsRef.current, + [selectedImage.id]: blobUrl + }; + setPreviewUrls(previewUrlsRef.current); + } catch (error) { + if (axios.isCancel?.(error) || error.name === "CanceledError") return; + + console.error("Failed to fetch original image blob", error); + setPreviewError(error); + } + } + + loadPreviewImage(); + + return () => { + controller.abort(); + }; + }, [modalState.open, selectedImage?.id]); + + useEffect(() => { + if (modalState.open && !selectedImage) { + setModalState({ open: false, index: 0 }); + } + }, [modalState.open, selectedImage]); + + const openEditorForImage = useCallback((image) => { + if (!image?.id) return; + + const newWindow = window.open( + `${window.location.protocol}//${window.location.host}/edit?documentId=${image.id}`, + "_blank", + "noopener,noreferrer" + ); + if (newWindow) newWindow.opener = null; + }, []); + const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" }); const hasMobileAccess = HasFeatureAccess({ bodyshop, featureName: "mobile" }); + const previewSrc = selectedImage ? previewUrls[selectedImage.id] : null; + const getLightboxImageSrc = useCallback( + (index) => { + const image = galleryImages.images[index]; + return image ? previewUrls[image.id] || image.src : undefined; + }, + [galleryImages.images, previewUrls] + ); + return (
@@ -147,30 +228,33 @@ function JobsDocumentsImgproxyComponent({ /> - {modalState.open && ( + {modalState.open && selectedImage && ( { - const newWindow = window.open( - `${window.location.protocol}//${window.location.host}/edit?documentId=${ - galleryImages.images[modalState.index].id - }`, - "_blank", - "noopener,noreferrer" - ); - if (newWindow) newWindow.opener = null; + openEditorForImage(selectedImage); }} /> ]} - mainSrc={galleryImages.images[modalState.index].fullsize} - nextSrc={galleryImages.images[(modalState.index + 1) % galleryImages.images.length].fullsize} - prevSrc={ + imageLoadErrorMessage={previewError ? t("general.errors.notfound") : undefined} + mainSrc={previewSrc || selectedImage.src} + mainSrcThumbnail={selectedImage.src} + nextSrc={getLightboxImageSrc((modalState.index + 1) % galleryImages.images.length)} + nextSrcThumbnail={galleryImages.images[(modalState.index + 1) % galleryImages.images.length]?.src} + prevSrc={getLightboxImageSrc( + (modalState.index + galleryImages.images.length - 1) % galleryImages.images.length + )} + prevSrcThumbnail={ galleryImages.images[(modalState.index + galleryImages.images.length - 1) % galleryImages.images.length] - .fullsize + ?.src } - onCloseRequest={() => setModalState({ open: false, index: 0 })} + reactModalProps={{ ariaHideApp: false }} + onCloseRequest={() => { + setModalState({ open: false, index: 0 }); + setPreviewError(null); + }} onMovePrevRequest={() => setModalState({ ...modalState, diff --git a/client/src/components/owners-list/owners-list.component.jsx b/client/src/components/owners-list/owners-list.component.jsx index eefec34ca..e6fe1efb9 100644 --- a/client/src/components/owners-list/owners-list.component.jsx +++ b/client/src/components/owners-list/owners-list.component.jsx @@ -11,12 +11,13 @@ import ResponsiveTable from "../responsive-table/responsive-table.component"; export default function OwnersListComponent({ loading, owners, total, refetch }) { const search = queryString.parse(useLocation().search); - const { - page - // sortcolumn, sortorder - } = search; + const { page, pageSize } = search; const history = useNavigate(); + const currentPage = Number.parseInt(page || "1", 10); + const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10); + const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize; + const [state, setState] = useState({ sortedInfo: {}, filteredInfo: { text: "" } @@ -71,10 +72,14 @@ export default function OwnersListComponent({ loading, owners, total, refetch }) ]; const handleTableChange = (pagination, filters, sorter) => { + const nextPageSize = pagination?.pageSize || currentPageSize; + const pageSizeChanged = nextPageSize !== currentPageSize; + setState({ ...state, filteredInfo: filters, sortedInfo: sorter }); const updatedSearch = { ...search, - page: pagination.current, + pageSize: nextPageSize, + page: pageSizeChanged ? 1 : pagination.current, sortcolumn: sorter.columnKey, sortorder: sorter.order }; @@ -119,7 +124,7 @@ export default function OwnersListComponent({ loading, owners, total, refetch }) > + { - const originalLine = linesToOrder?.[index]; - const jobLineId = isReturn ? originalLine?.joblineid : originalLine?.id; - - return { - ...p, - job_line_id: jobLineId, - ...(isReturn && { cm_received: false }) - }; + const forcedLines = buildSubmittedPartsOrderLines({ + submittedLines, + linesToOrder, + isReturn }); let insertResult; @@ -147,10 +143,7 @@ export function PartsOrderModalContainer({ type: isReturn ? "jobspartsreturn" : "jobspartsorder" }); - // Use linesToOrder from context instead of form values to preserve job line ids - const jobLineIds = (linesToOrder ?? []) - .filter((line) => (isReturn ? line.joblineid : line.id)) - .map((line) => (isReturn ? line.joblineid : line.id)); + const jobLineIds = getSubmittedPartsOrderJobLineIds(forcedLines); try { const jobLinesResult = await updateJobLines({ @@ -206,23 +199,20 @@ export function PartsOrderModalContainer({ isinhouse: true, date: dayjs(), total: 0, - billlines: forcedLines.map((p, index) => { - const originalLine = linesToOrder?.[index]; - return { - joblineid: isReturn ? originalLine?.joblineid : originalLine?.id, - actual_price: p.act_price, - actual_cost: 0, // p.act_price, - line_desc: p.line_desc, - line_remarks: p.line_remarks, - part_type: p.part_type, - quantity: p.quantity || 1, - applicable_taxes: { - local: false, - state: false, - federal: false - } - }; - }) + billlines: forcedLines.map((p) => ({ + joblineid: p.job_line_id, + actual_price: p.act_price, + actual_cost: 0, // p.act_price, + line_desc: p.line_desc, + line_remarks: p.line_remarks, + part_type: p.part_type, + quantity: p.quantity || 1, + applicable_taxes: { + local: false, + state: false, + federal: false + } + })) } } }); diff --git a/client/src/components/parts-order-modal/parts-order-modal.utils.js b/client/src/components/parts-order-modal/parts-order-modal.utils.js new file mode 100644 index 000000000..1517704ce --- /dev/null +++ b/client/src/components/parts-order-modal/parts-order-modal.utils.js @@ -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); +}; diff --git a/client/src/components/parts-order-modal/parts-order-modal.utils.test.js b/client/src/components/parts-order-modal/parts-order-modal.utils.test.js new file mode 100644 index 000000000..1c7309680 --- /dev/null +++ b/client/src/components/parts-order-modal/parts-order-modal.utils.test.js @@ -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 + } + ]); + }); +}); diff --git a/client/src/components/parts-queue-list/parts-queue.list.component.jsx b/client/src/components/parts-queue-list/parts-queue.list.component.jsx index 3cb5872f2..c14a62344 100644 --- a/client/src/components/parts-queue-list/parts-queue.list.component.jsx +++ b/client/src/components/parts-queue-list/parts-queue.list.component.jsx @@ -29,7 +29,10 @@ const mapStateToProps = createStructuredSelector({ export function PartsQueueListComponent({ bodyshop }) { const searchParams = queryString.parse(useLocation().search); - const { selected, sortcolumn, sortorder, statusFilters } = searchParams; + const { selected, sortcolumn, sortorder, statusFilters, page, pageSize } = searchParams; + const currentPage = Number.parseInt(page || "1", 10); + const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10); + const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize; const history = useNavigate(); const [filter, setFilter] = useLocalStorage("filter_parts_queue", null); const [viewTimeStamp, setViewTimeStamp] = useLocalStorage("parts_queue_timestamps", false); @@ -66,7 +69,11 @@ export function PartsQueueListComponent({ bodyshop }) { : []; const handleTableChange = (pagination, filters, sorter) => { - // searchParams.page = pagination.current; + const nextPageSize = pagination?.pageSize || currentPageSize; + const pageSizeChanged = nextPageSize !== currentPageSize; + + searchParams.pageSize = nextPageSize; + searchParams.page = pageSizeChanged ? 1 : pagination.current; searchParams.sortcolumn = sorter.columnKey; searchParams.sortorder = sorter.order; @@ -315,9 +322,10 @@ export function PartsQueueListComponent({ bodyshop }) { loading={loading} pagination={{ placement: "top", - pageSize: pageLimit - // current: parseInt(page || 1), - // total: data && data.jobs_aggregate.aggregate.count, + pageSize: currentPageSize, + current: currentPage, + showSizeChanger: true, + total: jobs.length }} columns={columns} mobileColumnKeys={["ro_number", "ownr_ln", "status", "vehicle", "partsstatus"]} diff --git a/client/src/components/print-center-jobs/print-center-jobs.component.jsx b/client/src/components/print-center-jobs/print-center-jobs.component.jsx index a33a387e1..b0a412f40 100644 --- a/client/src/components/print-center-jobs/print-center-jobs.component.jsx +++ b/client/src/components/print-center-jobs/print-center-jobs.component.jsx @@ -12,7 +12,7 @@ import Jobd3RdPartyModal from "../job-3rd-party-modal/job-3rd-party-modal.compon import PrintCenterItem from "../print-center-item/print-center-item.component"; import PrintCenterJobsLabels from "../print-center-jobs-labels/print-center-jobs-labels.component"; import PrintCenterSpeedPrint from "../print-center-speed-print/print-center-speed-print.component"; -import { bodyshopHasDmsKey } from "../../utils/dmsUtils"; +import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils"; import { selectTechnician } from "../../redux/tech/tech.selectors"; const mapStateToProps = createStructuredSelector({ @@ -36,6 +36,8 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia splitKey: bodyshop.imexshopid }); const hasDMSKey = bodyshopHasDmsKey(bodyshop); + const dmsMode = getDmsMode(bodyshop, "off"); + const isReynoldsMode = dmsMode === DMS_MAP.reynolds; const Templates = !hasDMSKey ? Object.keys(tempList) @@ -60,6 +62,7 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia (temp.regions && temp.regions[bodyshop.region_config]) || (temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true) ) + .filter((temp) => !isReynoldsMode || !temp.excludedDmsModes?.includes(dmsMode)) .filter((temp) => !technician || temp.group !== "financial"); const JobsReportsList = diff --git a/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx b/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx index dbb96a6ba..c12a7c6c9 100644 --- a/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx +++ b/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx @@ -431,6 +431,7 @@ export default function ProductionBoardCard({ technician, card, bodyshop, cardSe { const { t } = useTranslation(); const calculateTotal = (items, key, subKey) => { - return items.reduce((acc, item) => acc + (item[key]?.aggregate?.sum?.[subKey] || 0), 0); + return items.reduce((acc, item) => acc + (item?.[key]?.aggregate?.sum?.[subKey] ?? 0), 0); }; const calculateTotalAmount = (items, key) => { - return items.reduce((acc, item) => acc.add(Dinero(item[key]?.totals?.subtotal ?? Dinero())), Dinero({ amount: 0 })); + return items.reduce( + (acc, item) => acc.add(Dinero(item?.[key]?.totals?.subtotal ?? Dinero())), + Dinero({ amount: 0 }) + ); }; const calculateReducerTotalAmount = (lanes, key) => { @@ -67,58 +70,83 @@ const ProductionStatistics = ({ data, cardSettings, reducerData }) => { return value; }; + const filteredData = cardSettings.excludeSuspended === true ? data.filter((item) => item.suspended !== true) : data; + const filteredReducerData = + cardSettings.excludeSuspended === true + ? { + ...reducerData, + lanes: reducerData.lanes.map((lane) => ({ + ...lane, + cards: lane.cards.filter((card) => card.metadata.suspended !== true) + })) + } + : reducerData; + const totalHrs = cardSettings.totalHrs - ? parseFloat((calculateTotal(data, "labhrs", "mod_lb_hrs") + calculateTotal(data, "larhrs", "mod_lb_hrs")).toFixed(2)) + ? parseFloat( + ( + calculateTotal(filteredData, "labhrs", "mod_lb_hrs") + calculateTotal(filteredData, "larhrs", "mod_lb_hrs") + ).toFixed(2) + ) : null; const totalLAB = cardSettings.totalLAB - ? parseFloat(calculateTotal(data, "labhrs", "mod_lb_hrs").toFixed(2)) + ? parseFloat(calculateTotal(filteredData, "labhrs", "mod_lb_hrs").toFixed(2)) : null; const totalLAR = cardSettings.totalLAR - ? parseFloat(calculateTotal(data, "larhrs", "mod_lb_hrs").toFixed(2)) + ? parseFloat(calculateTotal(filteredData, "larhrs", "mod_lb_hrs").toFixed(2)) : null; - const jobsInProduction = cardSettings.jobsInProduction ? data.length : null; + const jobsInProduction = cardSettings.jobsInProduction ? filteredData.length : null; const totalAmountInProduction = cardSettings.totalAmountInProduction - ? calculateTotalAmount(data, "job_totals").toFormat("$0,0.00") + ? calculateTotalAmount(filteredData, "job_totals").toFormat("$0,0.00") : null; - const totalAmountOnBoard = reducerData && cardSettings.totalAmountOnBoard - ? calculateReducerTotalAmount(reducerData.lanes, "job_totals").toFormat("$0,0.00") - : null; + const totalAmountOnBoard = + filteredReducerData && cardSettings.totalAmountOnBoard + ? calculateReducerTotalAmount(filteredReducerData.lanes, "job_totals").toFormat("$0,0.00") + : null; - const totalHrsOnBoard = reducerData && cardSettings.totalHrsOnBoard - ? parseFloat(( - calculateReducerTotal(reducerData.lanes, "labhrs", "mod_lb_hrs") + - calculateReducerTotal(reducerData.lanes, "larhrs", "mod_lb_hrs") - ).toFixed(2)) - : null; + const totalHrsOnBoard = + filteredReducerData && cardSettings.totalHrsOnBoard + ? parseFloat( + ( + calculateReducerTotal(filteredReducerData.lanes, "labhrs", "mod_lb_hrs") + + calculateReducerTotal(filteredReducerData.lanes, "larhrs", "mod_lb_hrs") + ).toFixed(2) + ) + : null; - const totalLABOnBoard = reducerData && cardSettings.totalLABOnBoard - ? parseFloat(calculateReducerTotal(reducerData.lanes, "labhrs", "mod_lb_hrs").toFixed(2)) - : null; + const totalLABOnBoard = + filteredReducerData && cardSettings.totalLABOnBoard + ? parseFloat(calculateReducerTotal(filteredReducerData.lanes, "labhrs", "mod_lb_hrs").toFixed(2)) + : null; - const totalLAROnBoard = reducerData && cardSettings.totalLAROnBoard - ? parseFloat(calculateReducerTotal(reducerData.lanes, "larhrs", "mod_lb_hrs").toFixed(2)) - : null; + const totalLAROnBoard = + filteredReducerData && cardSettings.totalLAROnBoard + ? parseFloat(calculateReducerTotal(filteredReducerData.lanes, "larhrs", "mod_lb_hrs").toFixed(2)) + : null; - const jobsOnBoard = reducerData && cardSettings.jobsOnBoard - ? reducerData.lanes.reduce((acc, lane) => acc + lane.cards.length, 0) - : null; + const jobsOnBoard = + filteredReducerData && cardSettings.jobsOnBoard + ? filteredReducerData.lanes.reduce((acc, lane) => acc + lane.cards.length, 0) + : null; const tasksInProduction = cardSettings.tasksInProduction - ? data.reduce((acc, item) => acc + (item.tasks_aggregate?.aggregate?.count || 0), 0) + ? filteredData.reduce((acc, item) => acc + (item.tasks_aggregate?.aggregate?.count || 0), 0) : null; - const tasksOnBoard = reducerData && cardSettings.tasksOnBoard - ? reducerData.lanes.reduce((acc, lane) => { - return ( - acc + lane.cards.reduce((laneAcc, card) => laneAcc + (card.metadata.tasks_aggregate?.aggregate?.count || 0), 0) - ); - }, 0) - : null; + const tasksOnBoard = + filteredReducerData && cardSettings.tasksOnBoard + ? filteredReducerData.lanes.reduce((acc, lane) => { + return ( + acc + + lane.cards.reduce((laneAcc, card) => laneAcc + (card.metadata.tasks_aggregate?.aggregate?.count || 0), 0) + ); + }, 0) + : null; const statistics = mergeStatistics(statisticsItems, [ { id: 0, value: totalHrs, type: StatisticType.HOURS }, diff --git a/client/src/components/production-board-kanban/settings/StatisticsSettings.jsx b/client/src/components/production-board-kanban/settings/StatisticsSettings.jsx index 1645bc05d..d283f6122 100644 --- a/client/src/components/production-board-kanban/settings/StatisticsSettings.jsx +++ b/client/src/components/production-board-kanban/settings/StatisticsSettings.jsx @@ -14,7 +14,16 @@ const StatisticsSettings = ({ t, statisticsOrder, setStatisticsOrder, setHasChan }; return ( - + + + {t("production.settings.statistics.exclude_suspended")} + +
+ } + > {(provided) => ( diff --git a/client/src/components/production-board-kanban/settings/defaultKanbanSettings.js b/client/src/components/production-board-kanban/settings/defaultKanbanSettings.js index 1c7a264b7..93486a11d 100644 --- a/client/src/components/production-board-kanban/settings/defaultKanbanSettings.js +++ b/client/src/components/production-board-kanban/settings/defaultKanbanSettings.js @@ -91,7 +91,8 @@ const defaultKanbanSettings = { subtotal: false, statisticsOrder: statisticsItems.map((item) => item.id), selectedMdInsCos: [], - selectedEstimators: [] + selectedEstimators: [], + excludeSuspended: false }; const defaultFilters = { search: "", employeeId: null, alert: false }; diff --git a/client/src/components/report-center-modal/report-center-modal.component.jsx b/client/src/components/report-center-modal/report-center-modal.component.jsx index 7205939f2..77d006ae4 100644 --- a/client/src/components/report-center-modal/report-center-modal.component.jsx +++ b/client/src/components/report-center-modal/report-center-modal.component.jsx @@ -12,6 +12,7 @@ import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries"; import { selectReportCenter } from "../../redux/modals/modals.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors"; import DatePickerRanges from "../../utils/DatePickerRanges"; +import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils"; import { GenerateDocument } from "../../utils/RenderTemplate"; import { TemplateList } from "../../utils/TemplateConstants"; import dayjs from "../../utils/day"; @@ -48,12 +49,18 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) { const [loading, setLoading] = useState(false); const { t } = useTranslation(); const Templates = TemplateList("report_center"); + const dmsMode = getDmsMode(bodyshop, "off"); + const isReynoldsMode = dmsMode === DMS_MAP.reynolds; const ReportsList = Object.keys(Templates) .map((key) => Templates[key]) .filter((temp) => { const enhancedPayrollOn = Enhanced_Payroll.treatment === "on"; const adpPayrollOn = ADPPayroll.treatment === "on"; + if (isReynoldsMode && temp.excludedDmsModes?.includes(dmsMode)) { + return false; + } + if (enhancedPayrollOn && adpPayrollOn) { return temp.enhanced_payroll !== false || temp.adp_payroll !== false; } @@ -408,6 +415,6 @@ const restrictedReports = [ { key: "job_costing_ro_estimator", days: 183 }, { key: "job_lifecycle_date_detail", days: 183 }, { key: "job_lifecycle_date_summary", days: 183 }, - { key: "customer_list", days: 183 }, - { key: "customer_list_excel", days: 183 } + { key: "customer_list", days: 736 }, + { key: "customer_list_excel", days: 736 } ]; diff --git a/client/src/components/shop-employees/shop-employees-form.component.jsx b/client/src/components/shop-employees/shop-employees-form.component.jsx index 8218597b9..7ff148791 100644 --- a/client/src/components/shop-employees/shop-employees-form.component.jsx +++ b/client/src/components/shop-employees/shop-employees-form.component.jsx @@ -220,12 +220,16 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi }); const savedEmployee = result?.data?.insert_employees?.returning?.[0]; - syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues); - if (submitAction === "saveAndNew") { + if (isNewEmployee) { + resetEmployeeFormToCurrentData(); + } navigateToEmployee("new"); } else if (savedEmployee?.id) { + syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues); navigateToEmployee(savedEmployee.id); + } else { + syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues); } notification.success({ diff --git a/client/src/components/shop-employees/shop-employees-form.component.test.jsx b/client/src/components/shop-employees/shop-employees-form.component.test.jsx index dc022a96a..6da7ef3a6 100644 --- a/client/src/components/shop-employees/shop-employees-form.component.test.jsx +++ b/client/src/components/shop-employees/shop-employees-form.component.test.jsx @@ -3,7 +3,12 @@ import { Form } from "antd"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { useEffect } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { DELETE_VACATION, INSERT_EMPLOYEES, QUERY_EMPLOYEE_BY_ID, UPDATE_EMPLOYEE } from "../../graphql/employees.queries"; +import { + DELETE_VACATION, + INSERT_EMPLOYEES, + QUERY_EMPLOYEE_BY_ID, + UPDATE_EMPLOYEE +} from "../../graphql/employees.queries"; import { ShopEmployeesFormComponent } from "./shop-employees-form.component.jsx"; const insertEmployeesMock = vi.fn(); @@ -335,6 +340,15 @@ describe("ShopEmployeesFormComponent", () => { expect(formInstance.isFieldsTouched()).toBe(false); }); + await waitFor(() => { + expect(screen.getByRole("textbox", { name: "First Name" })).toHaveValue(""); + expect(screen.getByRole("textbox", { name: "Last Name" })).toHaveValue(""); + expect(screen.getByRole("textbox", { name: "Employee Number" })).toHaveValue(""); + expect(screen.getByRole("textbox", { name: "PIN" })).toHaveValue(""); + expect(screen.getByRole("textbox", { name: "Hire Date" })).toHaveValue(""); + }); + + expect(screen.getByText("New Employee")).toBeInTheDocument(); expect(navigateMock).toHaveBeenCalledWith({ search: "employeeId=new" }); diff --git a/client/src/components/shop-info/shop-info.general.component.jsx b/client/src/components/shop-info/shop-info.general.component.jsx index db7294df3..a12b84d04 100644 --- a/client/src/components/shop-info/shop-info.general.component.jsx +++ b/client/src/components/shop-info/shop-info.general.component.jsx @@ -12,6 +12,8 @@ import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.c import { buildSectionActionButton, renderListOrEmpty } from "../layout-form-row/config-list-actions.utils.jsx"; import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx"; import LayoutFormRow from "../layout-form-row/layout-form-row.component"; +import { selectBodyshop } from "../../redux/user/user.selectors"; +import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils.js"; import { INLINE_TITLE_GROUP_STYLE, INLINE_TITLE_HANDLE_STYLE, @@ -25,16 +27,21 @@ import { const timeZonesList = Intl.supportedValuesOf("timeZone"); -const mapStateToProps = createStructuredSelector({}); +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop +}); const mapDispatchToProps = () => ({ //setUserLanguage: language => dispatch(setUserLanguage(language)) }); export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoGeneral); -export function ShopInfoGeneral({ form }) { +export function ShopInfoGeneral({ form, bodyshop }) { const { t } = useTranslation(); const insuranceCompanies = Form.useWatch(["md_ins_cos"], form) || []; const duplicateInsuranceCompanyIndexes = getDuplicateIndexSetByNormalizedName(insuranceCompanies, "name"); + const hasDMSKey = bodyshop ? bodyshopHasDmsKey(bodyshop) : false; + const dmsMode = bodyshop ? getDmsMode(bodyshop, "off") : "none"; + const isReynoldsMode = hasDMSKey && dmsMode === DMS_MAP.reynolds; return (
@@ -174,7 +181,9 @@ export function ShopInfoGeneral({ form }) { >
-
{t("bodyshop.fields.scoreboard_setup.ignore_blocked_days")}
+
+ {t("bodyshop.fields.scoreboard_setup.ignore_blocked_days")} +
@@ -311,7 +320,12 @@ export function ShopInfoGeneral({ form }) { - + @@ -478,7 +492,12 @@ export function ShopInfoGeneral({ form }) {
{t("bodyshop.fields.system_settings.job_costing.use_paint_scale_data")}
- +
@@ -558,7 +577,12 @@ export function ShopInfoGeneral({ form }) { - + + {isReynoldsMode && ( + + + + )}
diff --git a/client/src/components/vehicles-list/vehicles-list.component.jsx b/client/src/components/vehicles-list/vehicles-list.component.jsx index 41cc1d764..5d6b33fc8 100644 --- a/client/src/components/vehicles-list/vehicles-list.component.jsx +++ b/client/src/components/vehicles-list/vehicles-list.component.jsx @@ -11,12 +11,13 @@ import ResponsiveTable from "../responsive-table/responsive-table.component"; export default function VehiclesListComponent({ loading, vehicles, total, refetch, basePath = "/manage" }) { const search = queryString.parse(useLocation().search); - const { - page - //sortcolumn, sortorder, - } = search; + const { page, pageSize } = search; const history = useNavigate(); + const currentPage = Number.parseInt(page || "1", 10); + const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10); + const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize; + const [state, setState] = useState({ sortedInfo: {}, filteredInfo: { text: "" } @@ -62,10 +63,14 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc ]; const handleTableChange = (pagination, filters, sorter) => { + const nextPageSize = pagination?.pageSize || currentPageSize; + const pageSizeChanged = nextPageSize !== currentPageSize; + setState({ ...state, filteredInfo: filters, sortedInfo: sorter }); const updatedSearch = { ...search, - page: pagination.current, + pageSize: nextPageSize, + page: pageSizeChanged ? 1 : pagination.current, sortcolumn: sorter.columnKey, sortorder: sorter.order }; @@ -106,7 +111,7 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc > ; const handleTableChange = (pagination, filters, sorter) => { - searchParams.page = pagination.current; + const nextPageSize = pagination?.pageSize || currentPageSize; + const pageSizeChanged = nextPageSize !== currentPageSize; + + searchParams.pageSize = nextPageSize; + searchParams.page = pageSizeChanged ? 1 : pagination.current; searchParams.sortcolumn = sorter.columnKey; searchParams.sortorder = sorter.order; if (filters.status) { @@ -191,8 +199,9 @@ export function ExportLogsPageComponent() { loading={loading} pagination={{ placement: "top", - pageSize: pageLimit, - current: parseInt(page || 1, 10), + pageSize: currentPageSize, + current: currentPage, + showSizeChanger: true, total: data && data.search_exportlog_aggregate.aggregate.count }} columns={columns} diff --git a/client/src/pages/jobs-admin/jobs-admin.page.jsx b/client/src/pages/jobs-admin/jobs-admin.page.jsx index e7f9606d8..22ea7577c 100644 --- a/client/src/pages/jobs-admin/jobs-admin.page.jsx +++ b/client/src/pages/jobs-admin/jobs-admin.page.jsx @@ -1,6 +1,6 @@ import { useMutation, useQuery } from "@apollo/client/react"; 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 { connect } from "react-redux"; 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 RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component"; 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 { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; -import { insertAuditTrail } from "../../redux/application/application.actions"; +import { CONVERT_JOB_TO_RO, GET_JOB_BY_PK } from "../../graphql/jobs.queries"; +import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; import { selectBodyshop } from "../../redux/user/user.selectors"; import { createStructuredSelector } from "reselect"; import { useSocket } from "../../contexts/SocketIO/useSocket"; @@ -302,7 +301,11 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop } ]} > - {bodyshop?.md_referral_sources?.map((s) => ( {s} @@ -379,7 +382,13 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop - diff --git a/client/src/pages/tech-assigned-prod-jobs/tech-assigned-prod-jobs.component.jsx b/client/src/pages/tech-assigned-prod-jobs/tech-assigned-prod-jobs.component.jsx index 322880710..cb4934c7f 100644 --- a/client/src/pages/tech-assigned-prod-jobs/tech-assigned-prod-jobs.component.jsx +++ b/client/src/pages/tech-assigned-prod-jobs/tech-assigned-prod-jobs.component.jsx @@ -27,12 +27,19 @@ const mapDispatchToProps = (dispatch) => ({ }); 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, { variables: { - teamIds: bodyshop.employee_teams - .filter((et) => et.employee_team_members.find((etm) => etm.employeeid === technician.id)) - .map((et) => et.id) - } + teamIds + }, + skip: !technicianId || !hasAssignedTeams }); const searchParams = queryString.parse(useLocation().search); @@ -177,7 +184,7 @@ export function TechAssignedProdJobs({ setTimeTicketTaskContext, technician, bod -