From 09ea6dff2b7d8c1b1fb6e756d54452b3f4eafbc2 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 13 Nov 2025 11:18:11 -0500 Subject: [PATCH] feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration -Cleaned up DMS key check (consolidated into a helper function), Clean up DMS post form and make it agnostic, same with customer selector. --- ...form-lines.extended.formitem.component.jsx | 5 +- .../bill-form/bill-form.component.jsx | 3 +- .../bill-form/bill-form.lines.component.jsx | 5 +- .../dms-allocations-summary.component.jsx | 133 ++--- .../cdk-customer-selector.jsx | 102 ++++ .../dms-customer-selector.component.jsx | 522 ++--------------- .../fortellis-customer-selector.jsx | 105 ++++ .../pbs-customer-selector.jsx | 93 +++ .../rr-customer-selector.jsx | 268 +++++++++ .../dms-log-events.component.jsx | 6 +- .../dms-post-form/cdklike-dms-post-form.jsx | 403 +++++++++++++ .../dms-post-form/dms-post-form.component.jsx | 546 +----------------- .../dms-post-form/rr-dms-post-form.jsx | 242 ++++++++ .../jobs-close-auto-allocate.component.jsx | 30 +- .../jobs-close-export-button.component.jsx | 3 +- .../jobs-detail-header.component.jsx | 3 +- .../labor-allocations-table.utility.js | 8 +- ...arts-order-list-table-drawer.component.jsx | 3 +- .../parts-order-list-table.component.jsx | 4 +- ...p-info.responsibilitycenters.component.jsx | 23 +- ....responsibilitycenters.taxes.component.jsx | 3 +- .../tech-job-clock-out-button.component.jsx | 12 +- .../time-ticket-modal.component.jsx | 10 +- .../accounting-payables.container.jsx | 7 +- .../accounting-payments.container.jsx | 9 +- .../accounting-receivables.container.jsx | 7 +- .../dms-payables/dms-payables.container.jsx | 3 +- client/src/pages/dms/dms.container.jsx | 490 ++++++++-------- .../pages/jobs-close/jobs-close.component.jsx | 11 +- client/src/utils/determineDMSType.js | 11 - client/src/utils/dmsUtils.js | 72 +++ client/src/utils/legacySocket.js | 16 + 32 files changed, 1747 insertions(+), 1411 deletions(-) create mode 100644 client/src/components/dms-customer-selector/cdk-customer-selector.jsx create mode 100644 client/src/components/dms-customer-selector/fortellis-customer-selector.jsx create mode 100644 client/src/components/dms-customer-selector/pbs-customer-selector.jsx create mode 100644 client/src/components/dms-customer-selector/rr-customer-selector.jsx create mode 100644 client/src/components/dms-post-form/cdklike-dms-post-form.jsx create mode 100644 client/src/components/dms-post-form/rr-dms-post-form.jsx delete mode 100644 client/src/utils/determineDMSType.js create mode 100644 client/src/utils/dmsUtils.js create mode 100644 client/src/utils/legacySocket.js diff --git a/client/src/components/bill-form-lines-extended/bill-form-lines.extended.formitem.component.jsx b/client/src/components/bill-form-lines-extended/bill-form-lines.extended.formitem.component.jsx index cc1de0d98..7b7bf1990 100644 --- a/client/src/components/bill-form-lines-extended/bill-form-lines.extended.formitem.component.jsx +++ b/client/src/components/bill-form-lines-extended/bill-form-lines.extended.formitem.component.jsx @@ -7,6 +7,7 @@ import { createStructuredSelector } from "reselect"; import { selectBodyshop } from "../../redux/user/user.selectors"; import CurrencyInput from "../form-items-formatted/currency-form-item.component"; import CiecaSelect from "../../utils/Ciecaselect"; +import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -44,7 +45,7 @@ export function BillFormItemsExtendedFormItem({ quantity: record.part_qty || 1, actual_price: record.act_price, cost_center: record.part_type - ? bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid || bodyshop.rr_dealerid + ? bodyshopHasDmsKey(bodyshop) ? record.part_type : responsibilityCenters.defaults && (responsibilityCenters.defaults.costs[record.part_type] || null) : null @@ -100,7 +101,7 @@ export function BillFormItemsExtendedFormItem({ diff --git a/client/src/components/bill-form/bill-form.component.jsx b/client/src/components/bill-form/bill-form.component.jsx index c15866838..6cdd301fc 100644 --- a/client/src/components/bill-form/bill-form.component.jsx +++ b/client/src/components/bill-form/bill-form.component.jsx @@ -22,6 +22,7 @@ import VendorSearchSelect from "../vendor-search-select/vendor-search-select.com import BillFormLines from "./bill-form.lines.component"; import { CalculateBillTotal } from "./bill-form.totals.utility"; import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx"; +import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -354,7 +355,7 @@ export function BillFormComponent({ - {bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid || bodyshop.rr_dealerid ? ( + {bodyshopHasDmsKey(bodyshop) ? ( diff --git a/client/src/components/bill-form/bill-form.lines.component.jsx b/client/src/components/bill-form/bill-form.lines.component.jsx index 99cdd081b..e36a5b628 100644 --- a/client/src/components/bill-form/bill-form.lines.component.jsx +++ b/client/src/components/bill-form/bill-form.lines.component.jsx @@ -10,6 +10,7 @@ import InstanceRenderManager from "../../utils/instanceRenderMgr"; import BillLineSearchSelect from "../bill-line-search-select/bill-line-search-select.component"; import BilllineAddInventory from "../billline-add-inventory/billline-add-inventory.component"; import CurrencyInput from "../form-items-formatted/currency-form-item.component"; +import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js"; const mapStateToProps = createStructuredSelector({ //currentUser: selectCurrentUser @@ -90,7 +91,7 @@ export function BillEnterModalLinesComponent({ actual_price: opt.cost, original_actual_price: opt.cost, cost_center: opt.part_type - ? bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid || bodyshop.rr_dealerid + ? bodyshopHasDmsKey(bodyshop) ? opt.part_type !== "PAE" ? opt.part_type : null @@ -322,7 +323,7 @@ export function BillEnterModalLinesComponent({ }, formInput: () => ( diff --git a/client/src/components/dms-allocations-summary/dms-allocations-summary.component.jsx b/client/src/components/dms-allocations-summary/dms-allocations-summary.component.jsx index 547dfcba1..06764b3d7 100644 --- a/client/src/components/dms-allocations-summary/dms-allocations-summary.component.jsx +++ b/client/src/components/dms-allocations-summary/dms-allocations-summary.component.jsx @@ -1,102 +1,97 @@ import { Alert, Button, Card, Table, Typography } from "antd"; -import { useEffect, useState } from "react"; +import { SyncOutlined } from "@ant-design/icons"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; -import { selectBodyshop } from "../../redux/user/user.selectors"; import Dinero from "dinero.js"; -import { SyncOutlined } from "@ant-design/icons"; +import { DMS_MAP } from "../../utils/dmsUtils"; + +import { selectBodyshop } from "../../redux/user/user.selectors"; import { pageLimit } from "../../utils/config"; -import { useSplitTreatments } from "@splitsoftware/splitio-react"; -import { useSocket } from "../../contexts/SocketIO/useSocket"; -import { determineDmsType } from "../../utils/determineDMSType"; const mapStateToProps = createStructuredSelector({ - //currentUser: selectCurrentUser bodyshop: selectBodyshop }); - -const mapDispatchToProps = () => ({ - //setUserLanguage: language => dispatch(setUserLanguage(language)) -}); +const mapDispatchToProps = () => ({}); export default connect(mapStateToProps, mapDispatchToProps)(DmsAllocationsSummary); -export function DmsAllocationsSummary({ socket, bodyshop, jobId, title }) { +/** + * DMS Allocations Summary component + * @param mode + * @param socket + * @param bodyshop + * @param jobId + * @param title + * @returns {JSX.Element} + * @constructor + */ +export function DmsAllocationsSummary({ mode, socket, bodyshop, jobId, title }) { const { t } = useTranslation(); const [allocationsSummary, setAllocationsSummary] = useState([]); - const { - treatments: { Fortellis } - } = useSplitTreatments({ - attributes: {}, - names: ["Fortellis"], - splitKey: bodyshop.imexshopid - }); - const { socket: wsssocket } = useSocket(); - const dms = determineDmsType(bodyshop); + // Resolve event name by mode (PBS reuses the CDK event per existing behavior) + const allocationsEvent = + mode === DMS_MAP.reynolds + ? "rr-calculate-allocations" + : mode === DMS_MAP.fortellis + ? "fortellis-calculate-allocations" + : /* "cdk" | "pbs" (legacy) */ "cdk-calculate-allocations"; - const fetchAllocations = () => { - // ✅ RR takes precedence over Fortellis - if (dms === "rr") { - wsssocket.emit("rr-calculate-allocations", jobId, (ack) => { - console.dir({ ack }); - setAllocationsSummary(ack); - socket.allocationsSummary = ack; - }); - } else if (Fortellis.treatment === "on") { - // Fortellis path (unchanged) - wsssocket.emit("fortellis-calculate-allocations", jobId, (ack) => { - setAllocationsSummary(ack); - socket.allocationsSummary = ack; - }); - } else if (socket.connected) { - socket.emit("cdk-calculate-allocations", jobId, (ack) => { - setAllocationsSummary(ack); - socket.allocationsSummary = ack; + const fetchAllocations = useCallback(() => { + if (!socket || !jobId || !mode) return; + + try { + socket.emit(allocationsEvent, jobId, (ack) => { + const list = Array.isArray(ack) ? ack : []; + setAllocationsSummary(list); + // Preserve side-channel used by the post form for discrepancy checks + socket.allocationsSummary = list; }); + } catch { + // Best-effort; leave table empty on error + setAllocationsSummary([]); + socket && (socket.allocationsSummary = []); } - }; + }, [socket, jobId, mode, allocationsEvent]); + // Initial + whenever mode/socket/jobId changes useEffect(() => { fetchAllocations(); - }, [socket, socket.connected, jobId, dms, Fortellis?.treatment]); + }, [fetchAllocations]); const columns = [ - { - title: t("jobs.fields.dms.center"), - dataIndex: "center", - key: "center" - }, + { title: t("jobs.fields.dms.center"), dataIndex: "center", key: "center" }, { title: t("jobs.fields.dms.sale"), dataIndex: "sale", key: "sale", - render: (text, record) => Dinero(record.sale).toFormat() + render: (_text, record) => Dinero(record.sale).toFormat() }, { title: t("jobs.fields.dms.cost"), dataIndex: "cost", key: "cost", - render: (text, record) => Dinero(record.cost).toFormat() + render: (_text, record) => Dinero(record.cost).toFormat() }, { title: t("jobs.fields.dms.sale_dms_acctnumber"), dataIndex: "sale_dms_acctnumber", key: "sale_dms_acctnumber", - render: (text, record) => record.profitCenter?.dms_acctnumber + render: (_text, record) => record.profitCenter?.dms_acctnumber }, { title: t("jobs.fields.dms.cost_dms_acctnumber"), dataIndex: "cost_dms_acctnumber", key: "cost_dms_acctnumber", - render: (text, record) => record.costCenter?.dms_acctnumber + render: (_text, record) => record.costCenter?.dms_acctnumber }, { title: t("jobs.fields.dms.dms_wip_acctnumber"), dataIndex: "dms_wip_acctnumber", key: "dms_wip_acctnumber", - render: (text, record) => record.costCenter?.dms_wip_acctnumber + render: (_text, record) => record.costCenter?.dms_wip_acctnumber } ]; @@ -104,7 +99,7 @@ export function DmsAllocationsSummary({ socket, bodyshop, jobId, title }) { + } @@ -112,6 +107,7 @@ export function DmsAllocationsSummary({ socket, bodyshop, jobId, title }) { {bodyshop.pbs_configuration?.disablebillwip && ( )} + { - const totals = - allocationsSummary && - allocationsSummary.reduce( - (acc, val) => { - return { - totalSale: acc.totalSale.add(Dinero(val.sale)), - totalCost: acc.totalCost.add(Dinero(val.cost)) - }; - }, - { - totalSale: Dinero(), - totalCost: Dinero() - } - ); + const totals = allocationsSummary?.reduce( + (acc, val) => ({ + totalSale: acc.totalSale.add(Dinero(val.sale)), + totalCost: acc.totalCost.add(Dinero(val.cost)) + }), + { totalSale: Dinero(), totalCost: Dinero() } + ) || { totalSale: Dinero(), totalCost: Dinero() }; return ( {t("general.labels.totals")} - {totals && totals.totalSale.toFormat()} - - { - // totals.totalCost.toFormat() - } - - - + {totals.totalSale.toFormat()} + + + ); }} diff --git a/client/src/components/dms-customer-selector/cdk-customer-selector.jsx b/client/src/components/dms-customer-selector/cdk-customer-selector.jsx new file mode 100644 index 000000000..861620832 --- /dev/null +++ b/client/src/components/dms-customer-selector/cdk-customer-selector.jsx @@ -0,0 +1,102 @@ +import { Button, Checkbox, Col, Table } from "antd"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { alphaSort } from "../../utils/sorters"; + +export default function CDKCustomerSelector({ bodyshop, socket }) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [customerList, setCustomerList] = useState([]); + const [selectedCustomer, setSelectedCustomer] = useState(null); + + useEffect(() => { + if (!socket) return; + const handleCdkSelectCustomer = (list) => { + setOpen(true); + setCustomerList(Array.isArray(list) ? list : []); + setSelectedCustomer(null); + }; + socket.on("cdk-select-customer", handleCdkSelectCustomer); + return () => { + socket.off("cdk-select-customer", handleCdkSelectCustomer); + }; + }, [socket]); + + const onUseSelected = () => { + if (!selectedCustomer) return; + setOpen(false); + socket.emit("cdk-selected-customer", selectedCustomer); + setSelectedCustomer(null); + }; + + const onUseGeneric = () => { + const generic = bodyshop.cdk_configuration?.generic_customer_number || null; + setOpen(false); + socket.emit("cdk-selected-customer", generic); + setSelectedCustomer(null); + }; + + const onCreateNew = () => { + setOpen(false); + socket.emit("cdk-selected-customer", null); + setSelectedCustomer(null); + }; + + if (!open) return null; + + const columns = [ + { title: t("jobs.fields.dms.id"), dataIndex: ["id", "value"], key: "id" }, + { + title: t("jobs.fields.dms.vinowner"), + dataIndex: "vinOwner", + key: "vinOwner", + render: (_t, r) => + }, + { + title: t("jobs.fields.dms.name1"), + dataIndex: ["name1", "fullName"], + key: "name1", + sorter: (a, b) => alphaSort(a.name1?.fullName, b.name1?.fullName) + }, + { + title: t("jobs.fields.dms.address"), + key: "address", + render: (record) => + `${record.address?.addressLine && record.address.addressLine[0]}, ${record.address?.city} ${ + record.address?.stateOrProvince + } ${record.address?.postalCode}` + } + ]; + + const rowKey = (r) => r.id?.value || r.customerId; + + return ( + +
( +
+ + + +
+ )} + pagination={{ position: "top" }} + columns={columns} + rowKey={rowKey} + dataSource={customerList} + rowSelection={{ + onSelect: (r) => { + const key = r.id?.value || r.customerId; + setSelectedCustomer(key ? String(key) : null); + }, + type: "radio", + selectedRowKeys: selectedCustomer ? [selectedCustomer] : [] + }} + /> + + ); +} diff --git a/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx b/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx index 92b62d95b..650349d39 100644 --- a/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx +++ b/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx @@ -1,14 +1,14 @@ -import { useSplitTreatments } from "@splitsoftware/splitio-react"; -import { Alert, Button, Checkbox, Col, message, Space, Table } from "antd"; -import { useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; +import { useMemo } from "react"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; -import { useSocket } from "../../contexts/SocketIO/useSocket"; -import { socket } from "../../pages/dms/dms.container"; + import { selectBodyshop } from "../../redux/user/user.selectors"; -import { alphaSort } from "../../utils/sorters"; -import { determineDmsType } from "../../utils/determineDMSType"; + +import RRCustomerSelector from "./rr-customer-selector"; +import FortellisCustomerSelector from "./fortellis-customer-selector"; +import CDKCustomerSelector from "./cdk-customer-selector"; +import PBSCustomerSelector from "./pbs-customer-selector"; +import { DMS_MAP } from "../../utils/dmsUtils"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -16,479 +16,39 @@ const mapStateToProps = createStructuredSelector({ const mapDispatchToProps = () => ({}); export default connect(mapStateToProps, mapDispatchToProps)(DmsCustomerSelector); -// ---------------- Helpers ---------------- -function normalizeRrList(list) { - if (!Array.isArray(list)) return []; - return list - .map((row) => { - const custNo = row.custNo || row.CustomerId || row.customerId || null; - const name = - row.name || - [row.CustomerName?.FirstName, row.CustomerName?.LastName].filter(Boolean).join(" ").trim() || - (custNo ? String(custNo) : ""); - if (!custNo) return null; - const vinOwner = !!(row.vinOwner ?? row.isVehicleOwner); +/** + * DMS Customer Selector component that renders the appropriate customer selector + * @param props + * @returns {JSX.Element|null} + * @constructor + */ +export function DmsCustomerSelector(props) { + const { bodyshop, jobid, socket, rrOptions = {} } = props; - const address = - row.address && typeof row.address === "object" - ? { - line1: row.address.line1 ?? row.address.addr1 ?? row.address.Address1 ?? undefined, - line2: row.address.line2 ?? row.address.addr2 ?? row.address.Address2 ?? undefined, - city: row.address.city ?? undefined, - state: row.address.state ?? row.address.stateOrProvince ?? undefined, - postalCode: row.address.postalCode ?? row.address.zip ?? undefined, - country: row.address.country ?? row.address.countryCode ?? undefined - } - : undefined; + // Centralized "mode" (provider + transport) + const mode = props.mode; - return { custNo: String(custNo), name, vinOwner, address }; - }) - .filter(Boolean); -} - -function rrAddressToString(addr) { - if (!addr) return ""; - const parts = [ - addr.line1, - addr.line2, - [addr.city, addr.state].filter(Boolean).join(" "), - addr.postalCode, - addr.country - ].filter(Boolean); - return parts.join(", "); -} - -export function DmsCustomerSelector({ - bodyshop, - jobid, - rrOpenRoLimit = false, - onRrOpenRoFinished, - rrCashierPending = false, - onRrCashierFinished -}) { - const { t } = useTranslation(); - const [customerList, setcustomerList] = useState([]); - const [open, setOpen] = useState(false); - const [selectedCustomer, setSelectedCustomer] = useState(null); - const [dmsType, setDmsType] = useState("cdk"); - const [refreshing, setRefreshing] = useState(false); - - const { - treatments: { Fortellis } - } = useSplitTreatments({ - attributes: {}, - names: ["Fortellis"], - splitKey: bodyshop.imexshopid - }); - - const { socket: wsssocket } = useSocket(); - const dms = useMemo(() => determineDmsType(bodyshop), [bodyshop]); - - // --- owner set (RR only) --- - const rrOwnerSet = useMemo(() => { - return new Set( - (Array.isArray(customerList) ? customerList : []) - .filter((c) => c?.vinOwner || c?.isVehicleOwner) - .map((c) => String(c.custNo)) - ); - }, [customerList]); - const rrHasVinOwner = rrOwnerSet.size > 0; - - // If cashiering is pending, surface this banner by opening selector - useEffect(() => { - if (dms === "rr" && rrCashierPending) { - setOpen(true); - setDmsType("rr"); - } - }, [dms, rrCashierPending]); - - useEffect(() => { - if (dms === "rr") { - const handleRrSelectCustomer = (list) => { - const normalized = normalizeRrList(list); - setOpen(true); - setDmsType("rr"); - setcustomerList(normalized); - const firstOwner = normalized.find((r) => r.vinOwner)?.custNo; - setSelectedCustomer(firstOwner ? String(firstOwner) : null); - setRefreshing(false); // stop any in-flight refresh spinner - }; - - wsssocket.on("rr-select-customer", handleRrSelectCustomer); - return () => { - wsssocket.off("rr-select-customer", handleRrSelectCustomer); - }; - } - - if (Fortellis.treatment === "on") { - const handleFortellisSelectCustomer = (list) => { - setOpen(true); - setDmsType("cdk"); - setcustomerList(Array.isArray(list) ? list : []); - setSelectedCustomer(null); - }; - wsssocket.on("fortellis-select-customer", handleFortellisSelectCustomer); - return () => { - wsssocket.off("fortellis-select-customer", handleFortellisSelectCustomer); - }; - } else { - const handleCdkSelectCustomer = (list) => { - setOpen(true); - setDmsType("cdk"); - setcustomerList(Array.isArray(list) ? list : []); - setSelectedCustomer(null); - }; - const handlePbsSelectCustomer = (list) => { - setOpen(true); - setDmsType("pbs"); - setcustomerList(Array.isArray(list) ? list : []); - setSelectedCustomer(null); - }; - socket.on("cdk-select-customer", handleCdkSelectCustomer); - socket.on("pbs-select-customer", handlePbsSelectCustomer); - return () => { - socket.off("cdk-select-customer", handleCdkSelectCustomer); - socket.off("pbs-select-customer", handlePbsSelectCustomer); - }; - } - }, [dms, Fortellis?.treatment, wsssocket]); - - useEffect(() => { - if (dmsType !== "rr" || !rrHasVinOwner) return; - const firstOwner = (customerList.find((c) => c.vinOwner) || {}).custNo; - if (firstOwner && String(selectedCustomer) !== String(firstOwner)) { - setSelectedCustomer(String(firstOwner)); - } - }, [dmsType, rrHasVinOwner, customerList]); - - const onUseSelected = () => { - if (!selectedCustomer) { - message.warning(t("general.actions.select")); - return; - } - - if (dmsType === "rr" && rrHasVinOwner && !rrOwnerSet.has(String(selectedCustomer))) { - message.warning( - "This VIN is already assigned in Reynolds. Only the VIN owner can be selected. To choose a different customer, change ownership in Reynolds first." - ); - return; - } - - if (dmsType === "rr") { - // Keep the selector open; server will raise rr-cashiering-required - wsssocket.emit("rr-selected-customer", { jobId: jobid, create: true }, (ack) => { - if (ack?.ok) { - message.success(t("dms.messages.customerCreated")); - // Keep dialog open; cashiering banner will appear via `rr-cashiering-required` - } else if (ack?.error) { - message.error(ack.error); - } - }); - return; - } - - setOpen(false); - if (Fortellis.treatment === "on") { - wsssocket.emit("fortellis-selected-customer", { selectedCustomerId: selectedCustomer, jobid }); - } else { - socket.emit(`${dmsType}-selected-customer`, selectedCustomer); - } - setSelectedCustomer(null); - }; - - const onUseGeneric = () => { - if (dmsType === "rr" && rrHasVinOwner) return; // not rendered in RR, but keep guard - const generic = bodyshop.cdk_configuration?.generic_customer_number || null; - - if (dmsType === "rr") { - return; - } else if (Fortellis.treatment === "on") { - setOpen(false); - wsssocket.emit("fortellis-selected-customer", { selectedCustomerId: generic, jobid }); - } else { - setOpen(false); - socket.emit(`${dmsType}-selected-customer`, generic); - } - setSelectedCustomer(null); - }; - - const onCreateNew = () => { - if (dmsType === "rr" && rrHasVinOwner) return; - - if (dmsType === "rr") { - // Keep open; server will raise rr-cashiering-required - wsssocket.emit("rr-selected-customer", { jobId: jobid, create: true }, (ack) => { - if (ack?.ok) { - if (ack.custNo) setSelectedCustomer(String(ack.custNo)); - message.success(t("dms.messages.customerCreated")); - } else if (ack?.error) { - message.error(ack.error); - } - }); - return; - } - - setOpen(false); - if (Fortellis.treatment === "on") { - wsssocket.emit("fortellis-selected-customer", { selectedCustomerId: null, jobid }); - } else { - socket.emit(`${dmsType}-selected-customer`, null); - } - setSelectedCustomer(null); - }; - - // NEW: trigger a re-run of the RR combined search - const refreshRrSearch = () => { - if (dmsType !== "rr") return; - setRefreshing(true); - - // Safety timeout so the spinner can't hang forever - const to = setTimeout(() => { - setRefreshing(false); - }, 12000); - - // Stop spinner on either outcome - const stop = () => { - clearTimeout(to); - setRefreshing(false); - wsssocket.off("export-failed", stop); - wsssocket.off("rr-select-customer", stop); - }; - wsssocket.once("rr-select-customer", stop); - wsssocket.once("export-failed", stop); - - // This re-runs the name+VIN multi-search and emits rr-select-customer - wsssocket.emit("rr-export-job", { jobId: jobid }); - }; - - const fortellisColumns = [ - { title: t("jobs.fields.dms.id"), dataIndex: "customerId", key: "id" }, - { - title: t("jobs.fields.dms.vinowner"), - dataIndex: "vinOwner", - key: "vinOwner", - render: (_t, r) => - }, - { - title: t("jobs.fields.dms.name1"), - dataIndex: ["customerName", "firstName"], - key: "firstName", - sorter: (a, b) => alphaSort(a.customerName?.firstName, b.customerName?.firstName) - }, - { - title: t("jobs.fields.dms.name1"), - dataIndex: ["customerName", "lastName"], - key: "lastName", - sorter: (a, b) => alphaSort(a.customerName?.lastName, b.customerName?.lastName) - }, - { - title: t("jobs.fields.dms.address"), - key: "address", - render: (record) => - `${record.postalAddress?.addressLine1 || ""}${ - record.postalAddress?.addressLine2 ? `, ${record.postalAddress.addressLine2}` : "" - }, ${record.postalAddress?.city || ""} ${record.postalAddress?.state || ""} ${ - record.postalAddress?.postalCode || "" - } ${record.postalAddress?.country || ""}` - } - ]; - - const cdkColumns = [ - { title: t("jobs.fields.dms.id"), dataIndex: ["id", "value"], key: "id" }, - { - title: t("jobs.fields.dms.vinowner"), - dataIndex: "vinOwner", - key: "vinOwner", - render: (_t, r) => - }, - { - title: t("jobs.fields.dms.name1"), - dataIndex: ["name1", "fullName"], - key: "name1", - sorter: (a, b) => alphaSort(a.name1?.fullName, b.name1?.fullName) - }, - { - title: t("jobs.fields.dms.address"), - key: "address", - render: (record) => - `${record.address?.addressLine && record.address.addressLine[0]}, ${record.address?.city} ${ - record.address?.stateOrProvince - } ${record.address?.postalCode}` - } - ]; - - const pbsColumns = [ - { title: t("jobs.fields.dms.id"), dataIndex: "ContactId", key: "ContactId" }, - { - title: t("jobs.fields.dms.name1"), - key: "name1", - sorter: (a, b) => alphaSort(a.LastName, b.LastName), - render: (_t, r) => `${r.FirstName || ""} ${r.LastName || ""}` - }, - { - title: t("jobs.fields.dms.address"), - key: "address", - render: (r) => `${r.Address}, ${r.City} ${r.State} ${r.ZipCode}` - } - ]; - - const rrColumns = [ - { title: t("jobs.fields.dms.id"), dataIndex: "custNo", key: "custNo" }, - { - title: t("jobs.fields.dms.vinowner"), - dataIndex: "vinOwner", - key: "vinOwner", - render: (_t, r) => - }, - { - title: t("jobs.fields.dms.name1"), - dataIndex: "name", - key: "name", - sorter: (a, b) => alphaSort(a?.name, b?.name) - }, - { - title: t("jobs.fields.dms.address"), - key: "address", - render: (record) => rrAddressToString(record.address) - } - ]; - - if (!open) return null; - - const columns = - dmsType === "rr" - ? rrColumns - : dmsType === "cdk" - ? Fortellis.treatment === "on" - ? fortellisColumns - : cdkColumns - : pbsColumns; - - const rowKeyFn = - dmsType === "rr" - ? (record) => record.custNo - : dmsType === "cdk" - ? (record) => record.id?.value || record.customerId - : (record) => record.ContactId; - - const rrDisableRow = (record) => { - if (dmsType !== "rr") return false; - if (!rrHasVinOwner) return false; - return !rrOwnerSet.has(String(record.custNo)); - }; - - return ( - -
( -
- {/* Open RO limit banner (from parent flag) */} - {dmsType === "rr" && rrOpenRoLimit && ( - -
- Reynolds has reached the maximum number of open Repair Orders for this Customer. Close or finalize - an RO in Reynolds, then click Finished to continue. -
-
- -
-
- } - /> - )} - - {/* NEW: Cashiering required banner */} - {dmsType === "rr" && rrCashierPending && ( - -
- We created the Repair Order in Reynolds. Please complete the cashiering/closeout steps in - Reynolds. When done, click Finished/Close to finalize and mark this export as - complete. -
-
- - - -
- - } - /> - )} - -
- - - {/* Hide "Use Generic" entirely in RR mode */} - {dmsType !== "rr" && ( - - )} - - -
- - {/* VIN ownership enforced with Refresh */} - {dmsType === "rr" && rrHasVinOwner && ( - -
- This VIN is already assigned in Reynolds. Only the VIN owner is selectable here. To use a - different customer, please change the vehicle ownership in Reynolds first, then return to complete - the export. -
- - - } - /> - )} - - )} - pagination={{ position: "top" }} - columns={columns} - rowKey={rowKeyFn} - dataSource={customerList} - rowSelection={{ - onSelect: (record) => { - const key = - dmsType === "rr" - ? record.custNo - : dmsType === "cdk" - ? record.id?.value || record.customerId - : record.ContactId; - setSelectedCustomer(key ? String(key) : null); - }, - type: "radio", - selectedRowKeys: selectedCustomer ? [selectedCustomer] : [], - getCheckboxProps: (record) => ({ - disabled: rrDisableRow(record) - }) - }} - /> - - ); + // Stable base props for children + const base = useMemo(() => ({ bodyshop, jobid, socket }), [bodyshop, jobid, socket]); + + switch (mode) { + case DMS_MAP.reynolds: { + // Map rrOptions to current RR prop shape (you can also just pass rrOptions through and unpack in RR) + const rrProps = { + rrOpenRoLimit: rrOptions.openRoLimit, + onRrOpenRoFinished: rrOptions.onOpenRoFinished, + rrCashierPending: rrOptions.cashierPending, + onRrCashierFinished: rrOptions.onCashierFinished + }; + return ; + } + case DMS_MAP.fortellis: + return ; + case DMS_MAP.cdk: + return ; + case DMS_MAP.pbs: + return ; + default: + return null; + } } diff --git a/client/src/components/dms-customer-selector/fortellis-customer-selector.jsx b/client/src/components/dms-customer-selector/fortellis-customer-selector.jsx new file mode 100644 index 000000000..6d9c05074 --- /dev/null +++ b/client/src/components/dms-customer-selector/fortellis-customer-selector.jsx @@ -0,0 +1,105 @@ +import { Button, Checkbox, Col, Table } from "antd"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { alphaSort } from "../../utils/sorters"; + +export default function FortellisCustomerSelector({ bodyshop, jobid, socket }) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [customerList, setCustomerList] = useState([]); + const [selectedCustomer, setSelectedCustomer] = useState(null); + + useEffect(() => { + if (!socket) return; + const handleFortellisSelectCustomer = (list) => { + setOpen(true); + setCustomerList(Array.isArray(list) ? list : []); + setSelectedCustomer(null); + }; + socket.on("fortellis-select-customer", handleFortellisSelectCustomer); + return () => { + socket.off("fortellis-select-customer", handleFortellisSelectCustomer); + }; + }, [socket]); + + const onUseSelected = () => { + if (!selectedCustomer) return; + setOpen(false); + socket.emit("fortellis-selected-customer", { selectedCustomerId: selectedCustomer, jobid }); + setSelectedCustomer(null); + }; + + const onUseGeneric = () => { + const generic = bodyshop.cdk_configuration?.generic_customer_number || null; + setOpen(false); + socket.emit("fortellis-selected-customer", { selectedCustomerId: generic, jobid }); + setSelectedCustomer(null); + }; + + const onCreateNew = () => { + setOpen(false); + socket.emit("fortellis-selected-customer", { selectedCustomerId: null, jobid }); + setSelectedCustomer(null); + }; + + if (!open) return null; + + const columns = [ + { title: t("jobs.fields.dms.id"), dataIndex: "customerId", key: "id" }, + { + title: t("jobs.fields.dms.vinowner"), + dataIndex: "vinOwner", + key: "vinOwner", + render: (_t, r) => + }, + { + title: t("jobs.fields.dms.name1"), + dataIndex: ["customerName", "firstName"], + key: "firstName", + sorter: (a, b) => alphaSort(a.customerName?.firstName, b.customerName?.firstName) + }, + { + title: t("jobs.fields.dms.name1"), + dataIndex: ["customerName", "lastName"], + key: "lastName", + sorter: (a, b) => alphaSort(a.customerName?.lastName, b.customerName?.lastName) + }, + { + title: t("jobs.fields.dms.address"), + key: "address", + render: (record) => + `${record.postalAddress?.addressLine1 || ""}${ + record.postalAddress?.addressLine2 ? `, ${record.postalAddress.addressLine2}` : "" + }, ${record.postalAddress?.city || ""} ${record.postalAddress?.state || ""} ${ + record.postalAddress?.postalCode || "" + } ${record.postalAddress?.country || ""}` + } + ]; + + return ( +
+
( +
+ + + +
+ )} + pagination={{ position: "top" }} + columns={columns} + rowKey={(r) => r.customerId} + dataSource={customerList} + rowSelection={{ + onSelect: (r) => setSelectedCustomer(r?.customerId ? String(r.customerId) : null), + type: "radio", + selectedRowKeys: selectedCustomer ? [selectedCustomer] : [] + }} + /> + + ); +} diff --git a/client/src/components/dms-customer-selector/pbs-customer-selector.jsx b/client/src/components/dms-customer-selector/pbs-customer-selector.jsx new file mode 100644 index 000000000..e2717327c --- /dev/null +++ b/client/src/components/dms-customer-selector/pbs-customer-selector.jsx @@ -0,0 +1,93 @@ +import { Button, Col, Table } from "antd"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { alphaSort } from "../../utils/sorters"; + +export default function PBSCustomerSelector({ bodyshop, socket }) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [customerList, setCustomerList] = useState([]); + const [selectedCustomer, setSelectedCustomer] = useState(null); + + useEffect(() => { + if (!socket) return; + const handlePbsSelectCustomer = (list) => { + setOpen(true); + setCustomerList(Array.isArray(list) ? list : []); + setSelectedCustomer(null); + }; + socket.on("pbs-select-customer", handlePbsSelectCustomer); + return () => { + socket.off("pbs-select-customer", handlePbsSelectCustomer); + }; + }, [socket]); + + const onUseSelected = () => { + if (!selectedCustomer) return; + setOpen(false); + socket.emit("pbs-selected-customer", selectedCustomer); + setSelectedCustomer(null); + }; + + // Restores old behavior: reuse the CDK-named generic number for PBS too, + // matching the previous single-component implementation. + const onUseGeneric = () => { + const generic = bodyshop?.cdk_configuration?.generic_customer_number || null; + if (!generic) return; + setOpen(false); + socket.emit("pbs-selected-customer", generic); + setSelectedCustomer(null); + }; + + const onCreateNew = () => { + setOpen(false); + socket.emit("pbs-selected-customer", null); + setSelectedCustomer(null); + }; + + if (!open) return null; + + const columns = [ + { title: t("jobs.fields.dms.id"), dataIndex: "ContactId", key: "ContactId" }, + { + title: t("jobs.fields.dms.name1"), + key: "name1", + sorter: (a, b) => alphaSort(a.LastName, b.LastName), + render: (_t, r) => `${r.FirstName || ""} ${r.LastName || ""}` + }, + { + title: t("jobs.fields.dms.address"), + key: "address", + render: (r) => `${r.Address}, ${r.City} ${r.State} ${r.ZipCode}` + } + ]; + + const hasGeneric = !!bodyshop?.cdk_configuration?.generic_customer_number; + + return ( + +
( +
+ + + +
+ )} + pagination={{ position: "top" }} + columns={columns} + rowKey={(r) => r.ContactId} + dataSource={customerList} + rowSelection={{ + onSelect: (r) => setSelectedCustomer(r?.ContactId ? String(r.ContactId) : null), + type: "radio", + selectedRowKeys: selectedCustomer ? [selectedCustomer] : [] + }} + /> + + ); +} diff --git a/client/src/components/dms-customer-selector/rr-customer-selector.jsx b/client/src/components/dms-customer-selector/rr-customer-selector.jsx new file mode 100644 index 000000000..9a1991b61 --- /dev/null +++ b/client/src/components/dms-customer-selector/rr-customer-selector.jsx @@ -0,0 +1,268 @@ +import { Alert, Button, Checkbox, Col, message, Space, Table } from "antd"; +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { alphaSort } from "../../utils/sorters"; + +const normalizeRrList = (list) => { + if (!Array.isArray(list)) return []; + return list + .map((row) => { + const custNo = row.custNo || row.CustomerId || row.customerId || null; + const name = + row.name || + [row.CustomerName?.FirstName, row.CustomerName?.LastName].filter(Boolean).join(" ").trim() || + (custNo ? String(custNo) : ""); + if (!custNo) return null; + const vinOwner = !!(row.vinOwner ?? row.isVehicleOwner); + + const address = + row.address && typeof row.address === "object" + ? { + line1: row.address.line1 ?? row.address.addr1 ?? row.address.Address1 ?? undefined, + line2: row.address.line2 ?? row.address.addr2 ?? row.address.Address2 ?? undefined, + city: row.address.city ?? undefined, + state: row.address.state ?? row.address.stateOrProvince ?? undefined, + postalCode: row.address.postalCode ?? row.address.zip ?? undefined, + country: row.address.country ?? row.address.countryCode ?? undefined + } + : undefined; + + return { custNo: String(custNo), name, vinOwner, address }; + }) + .filter(Boolean); +}; + +const rrAddressToString = (addr) => { + if (!addr) return ""; + const parts = [ + addr.line1, + addr.line2, + [addr.city, addr.state].filter(Boolean).join(" "), + addr.postalCode, + addr.country + ].filter(Boolean); + return parts.join(", "); +}; + +export default function RRCustomerSelector({ + jobid, + socket, + rrOpenRoLimit = false, + onRrOpenRoFinished, + rrCashierPending = false, + onRrCashierFinished +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [customerList, setCustomerList] = useState([]); + const [selectedCustomer, setSelectedCustomer] = useState(null); + const [refreshing, setRefreshing] = useState(false); + + // Show dialog automatically when cashiering is pending + useEffect(() => { + if (rrCashierPending) setOpen(true); + }, [rrCashierPending]); + + // Listen for RR customer selection list + useEffect(() => { + if (!socket) return; + const handleRrSelectCustomer = (list) => { + const normalized = normalizeRrList(list); + setOpen(true); + setCustomerList(normalized); + const firstOwner = normalized.find((r) => r.vinOwner)?.custNo; + setSelectedCustomer(firstOwner ? String(firstOwner) : null); + setRefreshing(false); + }; + socket.on("rr-select-customer", handleRrSelectCustomer); + return () => { + socket.off("rr-select-customer", handleRrSelectCustomer); + }; + }, [socket]); + + // VIN owner set + const rrOwnerSet = useMemo(() => { + return new Set(customerList.filter((c) => c?.vinOwner || c?.isVehicleOwner).map((c) => String(c.custNo))); + }, [customerList]); + const rrHasVinOwner = rrOwnerSet.size > 0; + + // Enforce VIN owner stays selected if present + useEffect(() => { + if (!rrHasVinOwner) return; + const firstOwner = (customerList.find((c) => c.vinOwner) || {}).custNo; + if (firstOwner && String(selectedCustomer) !== String(firstOwner)) { + setSelectedCustomer(String(firstOwner)); + } + }, [rrHasVinOwner, customerList, selectedCustomer]); + + const onUseSelected = () => { + if (!selectedCustomer) { + message.warning(t("general.actions.select")); + return; + } + if (rrHasVinOwner && !rrOwnerSet.has(String(selectedCustomer))) { + message.warning( + "This VIN is already assigned in Reynolds. Only the VIN owner can be selected. To choose a different customer, change ownership in Reynolds first." + ); + return; + } + socket.emit("rr-selected-customer", { jobId: jobid, custNo: String(selectedCustomer) }, (ack) => { + if (ack?.ok) { + message.success(t("dms.messages.customerSelected")); + } else if (ack?.error) { + message.error(ack.error); + } + }); + }; + + const onCreateNew = () => { + if (rrHasVinOwner) return; + socket.emit("rr-selected-customer", { jobId: jobid, create: true }, (ack) => { + if (ack?.ok) { + if (ack.custNo) setSelectedCustomer(String(ack.custNo)); + message.success(t("dms.messages.customerCreated")); + } else if (ack?.error) { + message.error(ack.error); + } + }); + }; + + const refreshRrSearch = () => { + setRefreshing(true); + const to = setTimeout(() => setRefreshing(false), 12000); + const stop = () => { + clearTimeout(to); + setRefreshing(false); + socket.off("export-failed", stop); + socket.off("rr-select-customer", stop); + }; + socket.once("rr-select-customer", stop); + socket.once("export-failed", stop); + socket.emit("rr-export-job", { jobId: jobid }); + }; + + if (!open) return null; + + const columns = [ + { title: t("jobs.fields.dms.id"), dataIndex: "custNo", key: "custNo" }, + { + title: t("jobs.fields.dms.vinowner"), + dataIndex: "vinOwner", + key: "vinOwner", + render: (_t, r) => + }, + { + title: t("jobs.fields.dms.name1"), + dataIndex: "name", + key: "name", + sorter: (a, b) => alphaSort(a?.name, b?.name) + }, + { + title: t("jobs.fields.dms.address"), + key: "address", + render: (record) => rrAddressToString(record.address) + } + ]; + + const rrDisableRow = (record) => { + if (!rrHasVinOwner) return false; + return !rrOwnerSet.has(String(record.custNo)); + }; + + return ( + +
( +
+ {/* Open RO limit banner */} + {rrOpenRoLimit && ( + +
+ Reynolds has reached the maximum number of open Repair Orders for this Customer. Close or finalize + an RO in Reynolds, then click Finished to continue. +
+
+ +
+
+ } + /> + )} + + {/* Cashiering step banner */} + {rrCashierPending && ( + +
+ We created the Repair Order in Reynolds. Please complete the cashiering/closeout steps in + Reynolds. When done, click Finished/Close to finalize and mark this export as + complete. +
+
+ + + +
+ + } + /> + )} + +
+ + {/* No generic in RR */} + +
+ + {rrHasVinOwner && ( + +
+ This VIN is already assigned in Reynolds. Only the VIN owner is selectable here. To use a + different customer, please change the vehicle ownership in Reynolds first, then return to complete + the export. +
+ + + } + /> + )} + + )} + pagination={{ position: "top" }} + columns={columns} + rowKey={(r) => r.custNo} + dataSource={customerList} + rowSelection={{ + onSelect: (record) => setSelectedCustomer(record?.custNo ? String(record.custNo) : null), + type: "radio", + selectedRowKeys: selectedCustomer ? [selectedCustomer] : [], + getCheckboxProps: (record) => ({ disabled: rrDisableRow(record) }) + }} + /> + + ); +} diff --git a/client/src/components/dms-log-events/dms-log-events.component.jsx b/client/src/components/dms-log-events/dms-log-events.component.jsx index 34f6f7988..d01afb230 100644 --- a/client/src/components/dms-log-events/dms-log-events.component.jsx +++ b/client/src/components/dms-log-events/dms-log-events.component.jsx @@ -3,17 +3,13 @@ import { useEffect, useMemo, useState } from "react"; import dayjs from "../../utils/day"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; -import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; import { selectDarkMode } from "../../redux/application/application.selectors.js"; const mapStateToProps = createStructuredSelector({ isDarkMode: selectDarkMode }); -const mapDispatchToProps = (dispatch) => ({ - setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)), - setSelectedHeader: (key) => dispatch(setSelectedHeader(key)) -}); +const mapDispatchToProps = () => ({}); export default connect(mapStateToProps, mapDispatchToProps)(DmsLogEvents); diff --git a/client/src/components/dms-post-form/cdklike-dms-post-form.jsx b/client/src/components/dms-post-form/cdklike-dms-post-form.jsx new file mode 100644 index 000000000..ed8d399d1 --- /dev/null +++ b/client/src/components/dms-post-form/cdklike-dms-post-form.jsx @@ -0,0 +1,403 @@ +import { DeleteFilled, DownOutlined } from "@ant-design/icons"; +import { + Button, + Card, + Col, + Divider, + Dropdown, + Form, + Input, + InputNumber, + Row, + Select, + Space, + Statistic, + Switch, + Tooltip, + Typography +} from "antd"; +import Dinero from "dinero.js"; +import { useTranslation } from "react-i18next"; +import { useMemo, useState } from "react"; +import i18n from "../../translations/i18n"; +import dayjs from "../../utils/day"; +import DmsCdkMakes from "../dms-cdk-makes/dms-cdk-makes.component"; +import DmsCdkMakesRefetch from "../dms-cdk-makes/dms-cdk-makes.refetch.component"; +import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx"; +import CurrencyInput from "../form-items-formatted/currency-form-item.component"; +import { DMS_MAP } from "../../utils/dmsUtils"; + +/** + * CDK-like DMS post form: + * - CDK / Fortellis / PBS + * - CDK vehicle details + make/model selection + * - Payer list with discrepancy gating + * - Submit: "{mode}-export-job" + * @param bodyshop + * @param socket + * @param job + * @param logsRef + * @param mode + * @returns {JSX.Element} + * @constructor + */ +export default function CdkLikePostForm({ bodyshop, socket, job, logsRef, mode }) { + const [form] = Form.useForm(); + const { t } = useTranslation(); + const [, /*unused*/ setTick] = useState(0); // handy if you need a forceUpdate later + + const initialValues = useMemo( + () => ({ + story: `${t("jobs.labels.dms.defaultstory", { + ro_number: job.ro_number, + ownr_nm: `${job.ownr_fn || ""} ${job.ownr_ln || ""} ${job.ownr_co_nm || ""}`.trim(), + ins_co_nm: job.ins_co_nm || "N/A", + clm_po: `${job.clm_no ? `${job.clm_no} ` : ""}${job.po_number || ""}` + }).trim()}.${ + job.area_of_damage?.impact1 + ? " " + + t("jobs.labels.dms.damageto", { + area_of_damage: (job.area_of_damage && job.area_of_damage.impact1.padStart(2, "0")) || "UNKNOWN" + }) + : "" + }`.slice(0, 239), + inservicedate: dayjs( + `${ + (job.v_model_yr && + (job.v_model_yr < 100 + ? job.v_model_yr >= (dayjs().year() + 1) % 100 + ? 1900 + parseInt(job.v_model_yr, 10) + : 2000 + parseInt(job.v_model_yr, 10) + : job.v_model_yr)) || + 2019 + }-01-01` + ), + journal: bodyshop.cdk_configuration?.default_journal + }), + [job, bodyshop, t] + ); + + // Payers helpers + const handlePayerSelect = (value, index) => { + form.setFieldsValue({ + payers: (form.getFieldValue("payers") || []).map((payer, mapIndex) => { + if (index !== mapIndex) return payer; + const cdkPayer = + bodyshop.cdk_configuration.payers && bodyshop.cdk_configuration.payers.find((i) => i.name === value); + if (!cdkPayer) return payer; + return { + ...cdkPayer, + dms_acctnumber: cdkPayer.dms_acctnumber, + controlnumber: job?.[cdkPayer.control_type] + }; + }) + }); + setTick((n) => n + 1); + }; + + const handleFinish = (values) => { + if (!socket) return; + + if (mode === DMS_MAP.fortellis) { + socket.emit("fortellis-export-job", { + jobid: job.id, + txEnvelope: { ...values, SubscriptionID: bodyshop.cdk_dealerid } + }); + } else { + socket.emit(`${mode}-export-job`, { jobid: job.id, txEnvelope: values }); + } + + logsRef?.current?.scrollIntoView({ behavior: "smooth" }); + }; + + // Totals & discrepancy + const totals = socket?.allocationsSummary + ? socket.allocationsSummary.reduce( + (acc, val) => ({ + totalSale: acc.totalSale.add(Dinero(val.sale)), + totalCost: acc.totalCost.add(Dinero(val.cost)) + }), + { totalSale: Dinero(), totalCost: Dinero() } + ) + : { totalSale: Dinero(), totalCost: Dinero() }; + + return ( + +
+ {/* TOP ROW */} + +
+ + + + + + + + + + + + + + + + + + + {/* CDK vehicle details (kept for CDK/Fortellis paths when dealer id exists) */} + {bodyshop.cdk_dealerid && ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + + + + + + + + + + + {/* Totals */} + + + + + + + {/* Payers list */} + + + {(fields, { add, remove }) => ( +
+ {fields.map((field, index) => ( + +
+ + + + + + + + + + + + + + + + + + + + {t("jobs.fields.dms.payer.controlnumber")}{" "} + ({ + key: idx, + label: key.name, + onClick: () => { + form.setFieldsValue({ + payers: (form.getFieldValue("payers") || []).map((row, mapIndex) => { + if (index !== mapIndex) return row; + return { ...row, controlnumber: key.controlnumber }; + }) + }); + } + })) ?? [] + }} + > + e.preventDefault()}> + + + + + } + name={[field.name, "controlnumber"]} + rules={[{ required: true }]} + > + + + + + + + {() => { + const payers = form.getFieldValue("payers"); + const row = payers?.[index]; + const cdkPayer = + bodyshop.cdk_configuration.payers && + bodyshop.cdk_configuration.payers.find((i) => i && row && i.name === row.name); + if (i18n.exists(`jobs.fields.${cdkPayer?.control_type}`)) + return
{cdkPayer && t(`jobs.fields.${cdkPayer?.control_type}`)}
; + else if (i18n.exists(`jobs.fields.dms.control_type.${cdkPayer?.control_type}`)) { + return
{cdkPayer && t(`jobs.fields.dms.control_type.${cdkPayer?.control_type}`)}
; + } else { + return null; + } + }} +
+ + + + ))} + + + + + )} + + + {/* Validation gates & summary */} + + {() => { + let totalAllocated = Dinero(); + const payers = form.getFieldValue("payers") || []; + payers.forEach((payer) => { + totalAllocated = totalAllocated.add(Dinero({ amount: Math.round((payer?.amount || 0) * 100) })); + }); + + const discrep = totals ? totals.totalSale.subtract(totalAllocated) : Dinero(); + + // gate: must have payers filled + zero discrepancy when we have a summary + const payersOk = + payers.length > 0 && + payers.every((p) => p?.name && p.dms_acctnumber && (p.amount ?? "") !== "" && p.controlnumber); + const nonRrDiscrepancyGate = socket?.allocationsSummary ? discrep.getAmount() !== 0 : true; + const disablePost = !payersOk || nonRrDiscrepancyGate; + + return ( + + + - + + = + + + + ); + }} + + + + ); +} diff --git a/client/src/components/dms-post-form/dms-post-form.component.jsx b/client/src/components/dms-post-form/dms-post-form.component.jsx index 84c2acc70..21dfe66e7 100644 --- a/client/src/components/dms-post-form/dms-post-form.component.jsx +++ b/client/src/components/dms-post-form/dms-post-form.component.jsx @@ -1,532 +1,40 @@ -import { DeleteFilled, DownOutlined, ReloadOutlined } from "@ant-design/icons"; -import { - Button, - Card, - Col, - Divider, - Dropdown, - Form, - Input, - InputNumber, - Row, - Select, - Space, - Statistic, - Switch, - Tooltip, - Typography -} from "antd"; -import Dinero from "dinero.js"; -import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; -import { determineDmsType } from "../../utils/determineDMSType"; import { selectBodyshop } from "../../redux/user/user.selectors"; -import i18n from "../../translations/i18n"; -import dayjs from "../../utils/day"; -import DmsCdkMakes from "../dms-cdk-makes/dms-cdk-makes.component"; -import DmsCdkMakesRefetch from "../dms-cdk-makes/dms-cdk-makes.refetch.component"; -import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx"; -import CurrencyInput from "../form-items-formatted/currency-form-item.component"; -import { useSocket } from "../../contexts/SocketIO/useSocket"; -import { useSplitTreatments } from "@splitsoftware/splitio-react"; -import { useEffect, useMemo, useState } from "react"; +import { DMS_MAP } from "../../utils/dmsUtils"; +import RRPostForm from "./rr-dms-post-form"; +import CdkLikePostForm from "./cdklike-dms-post-form"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop }); const mapDispatchToProps = () => ({}); + export default connect(mapStateToProps, mapDispatchToProps)(DmsPostForm); -export function DmsPostForm({ bodyshop, socket, job, logsRef }) { - const { - treatments: { Fortellis } - } = useSplitTreatments({ - attributes: {}, - names: ["Fortellis"], - splitKey: bodyshop.imexshopid - }); +/** + * DMS Post Form component that renders the appropriate post form + * @param mode + * @param bodyshop + * @param socket + * @param job + * @param logsRef + * @returns {JSX.Element|null} + * @constructor + */ +export function DmsPostForm({ mode, bodyshop, socket, job, logsRef }) { + switch (mode) { + case DMS_MAP.reynolds: + return ; - const [form] = Form.useForm(); - const { t } = useTranslation(); - const { socket: wsssocket } = useSocket(); + // CDK (legacy /ws), Fortellis (CDK-over-WSS), and PBS share the same UI; + // we pass mode down so the child can choose the correct event name. + case DMS_MAP.fortellis: + case DMS_MAP.cdk: + case DMS_MAP.pbs: + return ; - // Figure out DMS once and reuse - const dms = useMemo(() => determineDmsType(bodyshop), [bodyshop]); - - // ---------------- RR Advisors (unchanged behavior) ---------------- - const [advisors, setAdvisors] = useState([]); - const [advLoading, setAdvLoading] = useState(false); - - // Normalize advisor fields coming from various shapes - const getAdvisorNumber = (a) => a?.advisorId; - const getAdvisorLabel = (a) => `${a?.firstName || ""} ${a?.lastName || ""}`.trim(); - - const fetchRrAdvisors = (refresh = false) => { - if (!wsssocket) return; - setAdvLoading(true); - - // Listen for the server's broadcast - const onResult = (payload) => { - try { - const list = payload?.result ?? payload ?? []; - setAdvisors(Array.isArray(list) ? list : []); - } finally { - setAdvLoading(false); - wsssocket.off("rr-get-advisors:result", onResult); - } - }; - - wsssocket.once("rr-get-advisors:result", onResult); - - // Emit with refresh flag: server will bypass/rebuild cache when true - wsssocket.emit("rr-get-advisors", { departmentType: "B", refresh }, (ack) => { - if (ack?.ok) { - const list = ack.result ?? []; - setAdvisors(Array.isArray(list) ? list : []); - } else if (ack) { - // Preserve original logging semantics - console.error("Something went wrong fetching DMS Advisors"); - } - setAdvLoading(false); - wsssocket.off("rr-get-advisors:result", onResult); - }); - }; - - useEffect(() => { - if (dms === "rr") fetchRrAdvisors(false); - }, [dms, bodyshop?.id]); - - // ---------------- Payers helpers (non-RR) ---------------- - const handlePayerSelect = (value, index) => { - form.setFieldsValue({ - payers: (form.getFieldValue("payers") || []).map((payer, mapIndex) => { - if (index !== mapIndex) return payer; - const cdkPayer = - bodyshop.cdk_configuration.payers && bodyshop.cdk_configuration.payers.find((i) => i.name === value); - if (!cdkPayer) return payer; - - return { - ...cdkPayer, - dms_acctnumber: cdkPayer.dms_acctnumber, - controlnumber: job?.[cdkPayer.control_type] - }; - }) - }); - }; - - // ---------------- Submit (RR precedence preserved) ---------------- - const handleFinish = (values) => { - // RR takes precedence regardless of Fortellis split - if (dms === "rr") { - // values will include advisorNo (and makeOverride if provided) - wsssocket.emit("rr-export-job", { - bodyshopId: bodyshop?.id, - jobId: job.id, - job, - txEnvelope: values - }); - } else if (Fortellis.treatment === "on") { - // Fallback to existing Fortellis behavior - wsssocket.emit("fortellis-export-job", { - jobid: job.id, - txEnvelope: { ...values, SubscriptionID: bodyshop.cdk_dealerid } - }); - } else { - // CDK/PBS/etc. - socket.emit(`${dms}-export-job`, { jobid: job.id, txEnvelope: values }); - } - - // Scroll logs into view (original behavior) - logsRef?.current?.scrollIntoView({ behavior: "smooth" }); - }; - - return ( - -
= (dayjs().year() + 1) % 100 - ? 1900 + parseInt(job.v_model_yr, 10) - : 2000 + parseInt(job.v_model_yr, 10) - : job.v_model_yr)) || - 2019 - }-01-01` - ) - }} - > - {/* TOP ROW — bottom-aligned so the Refresh button sits flush */} - - {dms !== "rr" && ( -
- - - - - )} - - {dms === "rr" && ( - <> - {/* Advisor + inline Refresh (binding fixed via inner noStyle Form.Item) */} - - - - - - - - - )} - - - - - - - - - - - - - - - {/* CDK vehicle details (unchanged behavior) */} - {bodyshop.cdk_dealerid && ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - )} - - - - - - - - - - - - {/* Totals (unchanged) */} - - - - - - - {/* Non-RR payers list (parity with original) */} - {dms !== "rr" && ( - <> - - - {(fields, { add, remove }) => ( -
- {fields.map((field, index) => ( - -
- - - - - - - - - - - - - - - - - - - - {t("jobs.fields.dms.payer.controlnumber")}{" "} - ({ - key: idx, - label: key.name, - onClick: () => { - form.setFieldsValue({ - payers: (form.getFieldValue("payers") || []).map((row, mapIndex) => { - if (index !== mapIndex) return row; - return { ...row, controlnumber: key.controlnumber }; - }) - }); - } - })) ?? [] - }} - > - {/* Anchor trigger */} - e.preventDefault()}> - - - - - } - name={[field.name, "controlnumber"]} - rules={[{ required: true }]} - > - - - - - - - {() => { - const payers = form.getFieldValue("payers"); - const row = payers?.[index]; - const cdkPayer = - bodyshop.cdk_configuration.payers && - bodyshop.cdk_configuration.payers.find((i) => i && row && i.name === row.name); - if (i18n.exists(`jobs.fields.${cdkPayer?.control_type}`)) - return
{cdkPayer && t(`jobs.fields.${cdkPayer?.control_type}`)}
; - else if (i18n.exists(`jobs.fields.dms.control_type.${cdkPayer?.control_type}`)) { - return ( -
{cdkPayer && t(`jobs.fields.dms.control_type.${cdkPayer?.control_type}`)}
- ); - } else { - return null; - } - }} -
- - - - ))} - - - - - )} - - - )} - - {/* Validation gates & summary (unchanged logic) */} - - {() => { - // 1) Sum allocated payers - let totalAllocated = Dinero(); - const payers = form.getFieldValue("payers") || []; - payers.forEach((payer) => { - totalAllocated = totalAllocated.add(Dinero({ amount: Math.round((payer?.amount || 0) * 100) })); - }); - - // 2) Subtotal from socket.allocationsSummary (existing behavior) - const totals = - socket && socket.allocationsSummary - ? socket.allocationsSummary.reduce( - (acc, val) => ({ - totalSale: acc.totalSale.add(Dinero(val.sale)), - totalCost: acc.totalCost.add(Dinero(val.cost)) - }), - { totalSale: Dinero(), totalCost: Dinero() } - ) - : { totalSale: Dinero(), totalCost: Dinero() }; - - const discrep = totals ? totals.totalSale.subtract(totalAllocated) : Dinero(); - - // 3) Validation gates - const advisorOk = dms !== "rr" || !!form.getFieldValue("advisorNo"); - - // Require at least one complete payer row for non-RR - const payersOk = - dms === "rr" || - (payers.length > 0 && - payers.every((p) => p?.name && p.dms_acctnumber && (p.amount ?? "") !== "" && p.controlnumber)); - - // 4) Disable rules: - // - For non-RR: must have summary and zero discrepancy - // - For RR: ignore discrepancy rule, but require advisor - const nonRrDiscrepancyGate = dms !== "rr" && (socket.allocationsSummary ? discrep.getAmount() !== 0 : true); - - const disablePost = !advisorOk || !payersOk || nonRrDiscrepancyGate; - - return ( - - - - - - = - - - - ); - }} - - - - ); + default: + return null; + } } diff --git a/client/src/components/dms-post-form/rr-dms-post-form.jsx b/client/src/components/dms-post-form/rr-dms-post-form.jsx new file mode 100644 index 000000000..dfd0647e6 --- /dev/null +++ b/client/src/components/dms-post-form/rr-dms-post-form.jsx @@ -0,0 +1,242 @@ +import { ReloadOutlined } from "@ant-design/icons"; +import { + Button, + Card, + Col, + Divider, + Form, + Input, + InputNumber, + Row, + Select, + Space, + Statistic, + Tooltip, + Typography +} from "antd"; +import Dinero from "dinero.js"; +import { useTranslation } from "react-i18next"; +import { useEffect, useMemo, useState } from "react"; +import dayjs from "../../utils/day"; + +/** + * RR DMS Post Form component + * Submit: "rr-export-job" + * @param bodyshop + * @param socket + * @param job + * @param logsRef + * @returns {JSX.Element} + * @constructor + */ +export default function RRPostForm({ bodyshop, socket, job, logsRef }) { + const [form] = Form.useForm(); + const { t } = useTranslation(); + + // Advisors + const [advisors, setAdvisors] = useState([]); + const [advLoading, setAdvLoading] = useState(false); + + const getAdvisorNumber = (a) => a?.advisorId; + + const getAdvisorLabel = (a) => `${a?.firstName || ""} ${a?.lastName || ""}`.trim(); + + const fetchRrAdvisors = (refresh = false) => { + if (!socket) return; + setAdvLoading(true); + + const onResult = (payload) => { + try { + const list = payload?.result ?? payload ?? []; + setAdvisors(Array.isArray(list) ? list : []); + } finally { + setAdvLoading(false); + socket.off("rr-get-advisors:result", onResult); + } + }; + + socket.once("rr-get-advisors:result", onResult); + socket.emit("rr-get-advisors", { departmentType: "B", refresh }, (ack) => { + if (ack?.ok) { + const list = ack.result ?? []; + setAdvisors(Array.isArray(list) ? list : []); + } else if (ack) { + console.error("Something went wrong fetching DMS Advisors"); + } + setAdvLoading(false); + socket.off("rr-get-advisors:result", onResult); + }); + }; + + useEffect(() => { + fetchRrAdvisors(false); + }, [bodyshop?.id, socket]); + + const initialValues = useMemo( + () => ({ + story: `${t("jobs.labels.dms.defaultstory", { + ro_number: job.ro_number, + ownr_nm: `${job.ownr_fn || ""} ${job.ownr_ln || ""} ${job.ownr_co_nm || ""}`.trim(), + ins_co_nm: job.ins_co_nm || "N/A", + clm_po: `${job.clm_no ? `${job.clm_no} ` : ""}${job.po_number || ""}` + }).trim()}.${ + job.area_of_damage?.impact1 + ? " " + + t("jobs.labels.dms.damageto", { + area_of_damage: (job.area_of_damage && job.area_of_damage.impact1.padStart(2, "0")) || "UNKNOWN" + }) + : "" + }`.slice(0, 239), + inservicedate: dayjs( + `${ + (job.v_model_yr && + (job.v_model_yr < 100 + ? job.v_model_yr >= (dayjs().year() + 1) % 100 + ? 1900 + parseInt(job.v_model_yr, 10) + : 2000 + parseInt(job.v_model_yr, 10) + : job.v_model_yr)) || + 2019 + }-01-01` + ) + }), + [job, t] + ); + + const handleFinish = (values) => { + if (!socket) return; + socket.emit("rr-export-job", { + bodyshopId: bodyshop?.id, + jobId: job.id, + job, + txEnvelope: values + }); + logsRef?.current?.scrollIntoView({ behavior: "smooth" }); + }; + + // Discrepancy is ignored for RR; we still show totals for operator context + const totals = socket?.allocationsSummary + ? socket.allocationsSummary.reduce( + (acc, val) => ({ + totalSale: acc.totalSale.add(Dinero(val.sale)), + totalCost: acc.totalCost.add(Dinero(val.cost)) + }), + { totalSale: Dinero(), totalCost: Dinero() } + ) + : { totalSale: Dinero(), totalCost: Dinero() }; + + return ( + +
+ + {/* Advisor + inline Refresh */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Validation */} + + {() => { + const advisorOk = !!form.getFieldValue("advisorNo"); + return ( + + + = + + + ); + }} + + + + ); +} diff --git a/client/src/components/jobs-close-auto-allocate/jobs-close-auto-allocate.component.jsx b/client/src/components/jobs-close-auto-allocate/jobs-close-auto-allocate.component.jsx index 3c6a26a7d..7ed28f90e 100644 --- a/client/src/components/jobs-close-auto-allocate/jobs-close-auto-allocate.component.jsx +++ b/client/src/components/jobs-close-auto-allocate/jobs-close-auto-allocate.component.jsx @@ -6,6 +6,7 @@ import { createStructuredSelector } from "reselect"; import { logImEXEvent } from "../../firebase/firebase.utils"; import { selectBodyshop } from "../../redux/user/user.selectors"; import InstanceRenderManager from "../../utils/instanceRenderMgr"; +import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -14,6 +15,8 @@ const mapStateToProps = createStructuredSelector({ export function JobsCloseAutoAllocate({ bodyshop, joblines, form, disabled }) { const { t } = useTranslation(); + const hasDmsKey = bodyshopHasDmsKey(bodyshop); + const handleAllocate = (defaults) => { form.setFieldsValue({ joblines: joblines.map((jl) => { @@ -64,21 +67,20 @@ export function JobsCloseAutoAllocate({ bodyshop, joblines, form, disabled }) { handleAllocate(bodyshop.md_responsibility_centers.dms_defaults.find((x) => x.name === key)); }; - const menu = - bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid - ? { - items: bodyshop.md_responsibility_centers.dms_defaults.map((mapping) => ({ - key: mapping.name, - label: mapping.name, - disabled: disabled - })), - onClick: handleMenuClick - } - : { - items: [] - }; + const menu = hasDmsKey + ? { + items: bodyshop.md_responsibility_centers.dms_defaults.map((mapping) => ({ + key: mapping.name, + label: mapping.name, + disabled: disabled + })), + onClick: handleMenuClick + } + : { + items: [] + }; - return bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid ? ( + return hasDmsKey ? ( diff --git a/client/src/components/jobs-close-export-button/jobs-close-export-button.component.jsx b/client/src/components/jobs-close-export-button/jobs-close-export-button.component.jsx index 83480e72d..e811a9544 100644 --- a/client/src/components/jobs-close-export-button/jobs-close-export-button.component.jsx +++ b/client/src/components/jobs-close-export-button/jobs-close-export-button.component.jsx @@ -10,6 +10,7 @@ import { auth, logImEXEvent } from "../../firebase/firebase.utils"; import { INSERT_EXPORT_LOG } from "../../graphql/accounting.queries"; import { UPDATE_JOB } from "../../graphql/jobs.queries"; import { insertAuditTrail } from "../../redux/application/application.actions"; +import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import AuditTrailMapping from "../../utils/AuditTrailMappings"; import client from "../../utils/GraphQLClient"; @@ -45,7 +46,7 @@ export function JobsCloseExportButton({ bodyshop, currentUser, jobId, disabled, const handleQbxml = async () => { //Check if it's a CDK setup. - if (bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid) { + if (bodyshopHasDmsKey(bodyshop)) { history(`/manage/dms?jobId=${jobId}`); return; } diff --git a/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx b/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx index cc343d54d..c04c40cb4 100644 --- a/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx +++ b/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx @@ -29,6 +29,7 @@ import ProductionListColumnProductionNote from "../production-list-columns/produ import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component"; import "./jobs-detail-header.styles.scss"; import getPartsBasePath from "../../utils/getPartsBasePath.js"; +import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js"; const mapStateToProps = createStructuredSelector({ jobRO: selectJobReadOnly, @@ -309,7 +310,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is {`${job.v_vin || t("general.labels.na")}`} - {bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid || bodyshop.rr_dealerid ? ( + {bodyshopHasDmsKey(bodyshop) ? ( job.v_vin?.length !== 17 ? ( ) : null diff --git a/client/src/components/labor-allocations-table/labor-allocations-table.utility.js b/client/src/components/labor-allocations-table/labor-allocations-table.utility.js index 2a4340515..1246bad2f 100644 --- a/client/src/components/labor-allocations-table/labor-allocations-table.utility.js +++ b/client/src/components/labor-allocations-table/labor-allocations-table.utility.js @@ -1,4 +1,5 @@ import i18next from "i18next"; +import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js"; export const CalculateAllocationsTotals = (bodyshop, joblines, timetickets, adjustments = []) => { const responsibilitycenters = bodyshop.md_responsibility_centers; @@ -14,10 +15,9 @@ export const CalculateAllocationsTotals = (bodyshop, joblines, timetickets, adju const r = allCodes.reduce((acc, value) => { const r = { opcode: value, - cost_center: - bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid - ? i18next.t(`joblines.fields.lbr_types.${value && value.toUpperCase()}`) - : responsibilitycenters.defaults.costs[value], + cost_center: bodyshopHasDmsKey(bodyshop) + ? i18next.t(`joblines.fields.lbr_types.${value && value.toUpperCase()}`) + : responsibilitycenters.defaults.costs[value], mod_lbr_ty: value, total: joblines.reduce((acc2, val2) => { return val2.mod_lbr_ty === value ? acc2 + val2.mod_lb_hrs : acc2; diff --git a/client/src/components/parts-order-list-table/parts-order-list-table-drawer.component.jsx b/client/src/components/parts-order-list-table/parts-order-list-table-drawer.component.jsx index 08cb68d9e..f52025d0e 100644 --- a/client/src/components/parts-order-list-table/parts-order-list-table-drawer.component.jsx +++ b/client/src/components/parts-order-list-table/parts-order-list-table-drawer.component.jsx @@ -28,6 +28,7 @@ import PartsOrderDeleteLine from "../parts-order-delete-line/parts-order-delete- import PartsOrderLineBackorderButton from "../parts-order-line-backorder-button/parts-order-line-backorder-button.component"; import PartsReceiveModalContainer from "../parts-receive-modal/parts-receive-modal.container"; import PrintWrapper from "../print-wrapper/print-wrapper.component"; +import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js"; const mapStateToProps = createStructuredSelector({ jobRO: selectJobReadOnly, @@ -196,7 +197,7 @@ export function PartsOrderListTableDrawerComponent({ quantity: pol.quantity, actual_price: pol.act_price, cost_center: pol.jobline?.part_type - ? bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid || bodyshop.rr_dealerid + ? bodyshopHasDmsKey(bodyshop) ? pol.jobline.part_type !== "PAE" ? pol.jobline.part_type : null diff --git a/client/src/components/parts-order-list-table/parts-order-list-table.component.jsx b/client/src/components/parts-order-list-table/parts-order-list-table.component.jsx index d9ba58aaf..e9fdef8c7 100644 --- a/client/src/components/parts-order-list-table/parts-order-list-table.component.jsx +++ b/client/src/components/parts-order-list-table/parts-order-list-table.component.jsx @@ -20,6 +20,7 @@ import PartsReceiveModalContainer from "../parts-receive-modal/parts-receive-mod import PrintWrapper from "../print-wrapper/print-wrapper.component"; import PartsOrderDrawer from "./parts-order-list-table-drawer.component"; import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx"; +import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js"; const mapStateToProps = createStructuredSelector({ jobRO: selectJobReadOnly, @@ -69,6 +70,7 @@ export function PartsOrderListTableComponent({ const parts_orders = billsQuery.data ? billsQuery.data.parts_orders : []; const { refetch } = billsQuery; + const recordActions = (record, showView = false) => ( - {(bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid) && ( + {hasDMSKey && ( <> {bodyshop.rr_dealerid && } @@ -396,7 +399,7 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) { > */} - {(bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid) && ( + {hasDMSKey && ( )} - {(bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid) && ( + {hasDMSKey && ( - {(bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid) && ( + {hasDMSKey && ( - {(bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid) && ( + {hasDMSKey && ( <> @@ -3802,7 +3805,7 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) { > - {(bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid) && ( + {hasDMSKey && ( - {(bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid) && ( + {hasDMSKey && ( - {(bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid) && ( + {hasDMSKey && ( Adjustments} id="refund"> - {bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid ? ( + {hasDMSKey ? ( <> - {(bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid) && ( + {bodyshopHasDmsKey(bodyshop) && ( e.id === (technician && technician.id))[0]; + + const emps = bodyshop.employees.filter((e) => e.id === technician?.id)[0]; + const hasDmsKey = bodyshopHasDmsKey(bodyshop); const handleFinish = async (values) => { logImEXEvent("tech_clock_out_job"); @@ -167,10 +170,7 @@ export function TechClockOffButton({ lineTicketData.jobs_by_pk.lbr_adjustments ); - const fieldTypeToCheck = - bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid - ? "mod_lbr_ty" - : "cost_center"; + const fieldTypeToCheck = hasDmsKey ? "mod_lbr_ty" : "cost_center"; const costCenterDiff = Math.round( @@ -210,7 +210,7 @@ export function TechClockOffButton({ {item.cost_center === "timetickets.labels.shift" ? t(item.cost_center) - : bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid + : hasDmsKey ? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`) : item.cost_center} diff --git a/client/src/components/time-ticket-modal/time-ticket-modal.component.jsx b/client/src/components/time-ticket-modal/time-ticket-modal.component.jsx index 388e08590..aa72dea96 100644 --- a/client/src/components/time-ticket-modal/time-ticket-modal.component.jsx +++ b/client/src/components/time-ticket-modal/time-ticket-modal.component.jsx @@ -14,6 +14,7 @@ import { import JobSearchSelect from "../job-search-select/job-search-select.component"; import LaborAllocationsTable from "../labor-allocations-table/labor-allocations-table.component"; import { CalculateAllocationsTotals } from "../labor-allocations-table/labor-allocations-table.utility"; +import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js"; import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component"; import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component"; @@ -127,7 +128,7 @@ export function TimeTicketModalComponent({ onSelect={(value) => { const emps = employeeAutoCompleteOptions && employeeAutoCompleteOptions.filter((e) => e.id === value)[0]; - form.setFieldsValue({ flat_rate: emps && emps.flat_rate }); + form.setFieldsValue({ flat_rate: emps?.flat_rate }); }} /> @@ -189,10 +190,7 @@ export function TimeTicketModalComponent({ lineTicketData.jobs_by_pk.lbr_adjustments ); - const fieldTypeToCheck = - bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid - ? "mod_lbr_ty" - : "cost_center"; + const fieldTypeToCheck = bodyshopHasDmsKey(bodyshop) ? "mod_lbr_ty" : "cost_center"; const costCenterDiff = Math.round( @@ -266,7 +264,7 @@ export function TimeTicketModalComponent({ if (!value) return Promise.resolve(); if (!clockon && value) return Promise.reject(t("timetickets.validation.clockoffwithoutclockon")); // TODO - Verify this exists - if (value && value.isSameOrAfter && !value.isSameOrAfter(clockon)) + if (value?.isSameOrAfter && !value.isSameOrAfter(clockon)) return Promise.reject(t("timetickets.validation.clockoffmustbeafterclockon")); return Promise.resolve(); diff --git a/client/src/pages/accounting-payables/accounting-payables.container.jsx b/client/src/pages/accounting-payables/accounting-payables.container.jsx index 3104c84af..a095fc799 100644 --- a/client/src/pages/accounting-payables/accounting-payables.container.jsx +++ b/client/src/pages/accounting-payables/accounting-payables.container.jsx @@ -15,6 +15,7 @@ import FeatureWrapperComponent from "../../components/feature-wrapper/feature-wr import InstanceRenderManager from "../../utils/instanceRenderMgr"; import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component"; import { Card } from "antd"; +import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -54,11 +55,7 @@ export function AccountingPayablesContainer({ bodyshop, setBreadcrumbs, setSelec if (error) return ; const noPath = - !partnerVersion?.qbpath && - !( - bodyshop && - (bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid || bodyshop.accountingconfig.qbo) - ); + !partnerVersion?.qbpath && !(bodyshop && (bodyshopHasDmsKey(bodyshop) || bodyshop?.accountingconfig?.qbo)); return (
diff --git a/client/src/pages/accounting-payments/accounting-payments.container.jsx b/client/src/pages/accounting-payments/accounting-payments.container.jsx index cea80853f..0cf739f53 100644 --- a/client/src/pages/accounting-payments/accounting-payments.container.jsx +++ b/client/src/pages/accounting-payments/accounting-payments.container.jsx @@ -15,6 +15,7 @@ import FeatureWrapperComponent from "../../components/feature-wrapper/feature-wr import InstanceRenderManager from "../../utils/instanceRenderMgr"; import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component"; import { Card } from "antd"; +import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -52,12 +53,10 @@ export function AccountingPaymentsContainer({ bodyshop, setBreadcrumbs, setSelec }); if (error) return ; + const noPath = - !partnerVersion?.qbpath && - !( - bodyshop && - (bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid || bodyshop.accountingconfig.qbo) - ); + !partnerVersion?.qbpath && !(bodyshop && (bodyshopHasDmsKey(bodyshop) || bodyshop?.accountingconfig?.qbo)); + return (
; const noPath = - !partnerVersion?.qbpath && - !( - bodyshop && - (bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid || bodyshop.accountingconfig.qbo) - ); + !partnerVersion?.qbpath && !(bodyshop && (bodyshopHasDmsKey(bodyshop) || bodyshop?.accountingconfig?.qbo)); return (
diff --git a/client/src/pages/dms-payables/dms-payables.container.jsx b/client/src/pages/dms-payables/dms-payables.container.jsx index c49b0985f..fa57109bb 100644 --- a/client/src/pages/dms-payables/dms-payables.container.jsx +++ b/client/src/pages/dms-payables/dms-payables.container.jsx @@ -90,6 +90,7 @@ export function DmsContainer({ setBreadcrumbs, setSelectedHeader }) { }); if (socket.disconnected) socket.connect(); + return () => { socket.removeAllListeners(); socket.disconnect(); @@ -139,7 +140,7 @@ export function DmsContainer({ setBreadcrumbs, setSelectedHeader }) { } > - +
diff --git a/client/src/pages/dms/dms.container.jsx b/client/src/pages/dms/dms.container.jsx index 80c6a5e68..9650d912b 100644 --- a/client/src/pages/dms/dms.container.jsx +++ b/client/src/pages/dms/dms.container.jsx @@ -3,27 +3,23 @@ import { Link, useLocation, useNavigate } from "react-router-dom"; import { connect } from "react-redux"; import { useTranslation } from "react-i18next"; import { createStructuredSelector } from "reselect"; -import SocketIO from "socket.io-client"; import queryString from "query-string"; import { useQuery } from "@apollo/client"; import { Button, Card, Col, Result, Row, Select, Space, Switch } from "antd"; import { useSplitTreatments } from "@splitsoftware/splitio-react"; - -import { auth } from "../../firebase/firebase.utils"; - import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { QUERY_JOB_EXPORT_DMS } from "../../graphql/jobs.queries"; - import { selectBodyshop } from "../../redux/user/user.selectors"; import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; import InstanceRenderManager from "../../utils/instanceRenderMgr"; import AuditTrailMapping from "../../utils/AuditTrailMappings"; -import { determineDmsType } from "../../utils/determineDmsType"; -import { OwnerNameDisplayFunction } from "../../components/owner-name-display/owner-name-display.component"; +import { bodyshopHasDmsKey, DMS_MAP, getDmsMode, isWssMode } from "../../utils/dmsUtils.js"; +import legacySocket from "../../utils/legacySocket"; +import { OwnerNameDisplayFunction } from "../../components/owner-name-display/owner-name-display.component"; import AlertComponent from "../../components/alert/alert.component"; import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component"; import DmsPostForm from "../../components/dms-post-form/dms-post-form.component"; @@ -43,30 +39,37 @@ const mapDispatchToProps = (dispatch) => ({ export default connect(mapStateToProps, mapDispatchToProps)(DmsContainer); -// Legacy /ws socket (CDK/PBS) -export const socket = SocketIO(import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "", { - path: "/ws", - withCredentials: true, - auth: async (callback) => { - const token = auth.currentUser && (await auth.currentUser.getIdToken()); - callback({ token }); +const DMS_SOCKET_EVENTS = { + [DMS_MAP.reynolds]: { + log: "rr-log-event", + partialResult: "rr-export-job:result", + cashierNeeded: "rr-cashiering-required", + exportSuccess: "export-success", + exportFailed: "export-failed" + }, + [DMS_MAP.fortellis]: { + log: "fortellis-log-event", + exportSuccess: "export-success", + exportFailed: "export-failed" + }, + [DMS_MAP.cdk]: { + log: "log-event", + exportSuccess: "export-success", + exportFailed: "export-failed" + }, + [DMS_MAP.pbs]: { + log: "log-event", + exportSuccess: "export-success", + exportFailed: "export-failed" } -}); +}; export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, insertAuditTrail }) { const { t } = useTranslation(); - const dms = determineDmsType(bodyshop); - const history = useNavigate(); const search = queryString.parse(useLocation().search); - - const [logLevel, setLogLevel] = useState(dms === "pbs" ? "INFO" : "DEBUG"); - const [logs, setLogs] = useState([]); - const [detailsOpen, setDetailsOpen] = useState(false); // false => button shows "Expand All" - const [detailsNonce, setDetailsNonce] = useState(0); // forces child to react to toggles - const [colorizeJson, setColorizeJson] = useState(false); // default: OFF - const { jobId } = search; + const notification = useNotification(); const { @@ -77,11 +80,31 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse splitKey: bodyshop.imexshopid }); - // New unified wss socket (Fortellis, RR) + // Compute a single normalized mode and pick the proper socket + const mode = getDmsMode(bodyshop, Fortellis.treatment); // "rr" | "fortellis" | "cdk" | "pbs" | "none" + const { socket: wsssocket } = useSocket(); - const activeSocket = useMemo(() => { - return dms === "rr" || (dms === "cdk" && Fortellis.treatment === "on") ? wsssocket : socket; - }, [dms, Fortellis.treatment, wsssocket, socket]); + const activeSocket = useMemo(() => (isWssMode(mode) ? wsssocket : legacySocket), [mode, wsssocket]); + + const [isConnected, setIsConnected] = useState(!!activeSocket?.connected); + + // One place to set log level + const [logLevel, setLogLevel] = useState(mode === DMS_MAP.pbs ? "INFO" : "DEBUG"); + + const setActiveLogLevel = (level) => { + if (!activeSocket) return; + activeSocket.emit("set-log-level", level); + }; + + const [logs, setLogs] = useState([]); + const [detailsOpen, setDetailsOpen] = useState(false); + const [detailsNonce, setDetailsNonce] = useState(0); + const [colorizeJson, setColorizeJson] = useState(false); + + const [rrOpenRoLimit, setRrOpenRoLimit] = useState(false); + const clearRrOpenRoLimit = () => setRrOpenRoLimit(false); + + const [rrCashierPending, setRrCashierPending] = useState(false); const { loading, error, data } = useQuery(QUERY_JOB_EXPORT_DMS, { variables: { id: jobId }, @@ -97,45 +120,51 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse setDetailsNonce((n) => n + 1); }; - const [rrOpenRoLimit, setRrOpenRoLimit] = useState(false); - const clearRrOpenRoLimit = () => setRrOpenRoLimit(false); + // Channel names per mode to avoid branching everywhere + const channels = useMemo(() => DMS_SOCKET_EVENTS[mode] || {}, [mode]); - // NEW: RR “cashiering required” UX hold - const [rrCashierPending, setRrCashierPending] = useState(false); + const providerLabel = useMemo( + () => + ({ + [DMS_MAP.reynolds]: "Reynolds", + [DMS_MAP.fortellis]: "Fortellis", + [DMS_MAP.cdk]: "CDK", + [DMS_MAP.pbs]: "PBS" + })[mode] || "DMS", + [mode] + ); + + const transportLabel = isWssMode(mode) ? "App Socket (WSS)" : "Legacy Socket (WS)"; + + const bannerMessage = `Posting to ${providerLabel} | ${transportLabel} | ${ + isConnected ? "Connected" : "Disconnected" + }`; const handleExportFailed = (payload = {}) => { - const { title, friendlyMessage, error, severity, errorCode, vendorStatusCode } = payload; + const { title, friendlyMessage, error: errText, severity, errorCode, vendorStatusCode } = payload; const msg = friendlyMessage || - error || + errText || t("dms.errors.exportfailedgeneric", "We couldn't complete the export. Please try again."); - const vendorTitle = title || (dms === "rr" ? "Reynolds" : "DMS"); + const vendorTitle = title || (mode === DMS_MAP.reynolds ? "Reynolds" : "DMS"); - // Detect the specific RR “max open ROs” case const isRrOpenRoLimit = - dms === "rr" && + mode === DMS_MAP.reynolds && (vendorStatusCode === 507 || /MAX_OPEN_ROS/i.test(String(errorCode || "")) || /maximum number of open repair orders/i.test(String(msg || "").toLowerCase())); - // Soft/warn default for known cases const sev = severity || (isRrOpenRoLimit ? "warning" : "error"); - // Show toast for *other* failures; for the open RO limit, switch to blocking banner UX instead. if (!isRrOpenRoLimit) { const notifyKind = sev === "warning" && typeof notification.warning === "function" ? "warning" : "error"; - notification[notifyKind]({ - message: vendorTitle, - description: msg, - duration: 10 - }); + notification[notifyKind]({ message: vendorTitle, description: msg, duration: 10 }); } else { setRrOpenRoLimit(true); } - // Mirror to the on-screen log card setLogs((prev) => [ ...prev, { @@ -147,254 +176,218 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse ]); }; + // keep this in sync if mode/socket flips + useEffect(() => { + setIsConnected(!!activeSocket?.connected); + }, [activeSocket]); + useEffect(() => { document.title = t("titles.dms", { - app: InstanceRenderManager({ - imex: "$t(titles.imexonline)", - rome: "$t(titles.romeonline)" - }) + app: InstanceRenderManager({ imex: "$t(titles.imexonline)", rome: "$t(titles.romeonline)" }) }); setSelectedHeader("dms"); setBreadcrumbs([ - { - link: "/manage/accounting/receivables", - label: t("titles.bc.accounting-receivables") - }, - { - link: "/manage/dms", - label: t("titles.bc.dms") - } + { link: "/manage/accounting/receivables", label: t("titles.bc.accounting-receivables") }, + { link: "/manage/dms", label: t("titles.bc.dms") } ]); }, [t, setBreadcrumbs, setSelectedHeader]); + // Socket wiring (mode-aware) useEffect(() => { - // ✅ RR path uses WSS and has two-step flow - if (dms === "rr") { - // set log level on connect and immediately - wsssocket.emit("set-log-level", logLevel); + if (!activeSocket) return; - const handleConnect = () => wsssocket.emit("set-log-level", logLevel); - const handleReconnect = () => - setLogs((prev) => [ - ...prev, - { timestamp: new Date(), level: "warn", message: "Reconnected to RR Export Service" } - ]); - const handleConnectError = (err) => { - console.log(`connect_error due to ${err}`, err); - notification.error({ message: err.message }); - }; - - const handleLogEvent = (payload = {}) => { - const normalized = { - timestamp: payload.timestamp ? new Date(payload.timestamp) : payload.ts ? new Date(payload.ts) : new Date(), - level: (payload.level || "INFO").toUpperCase(), - message: payload.message || payload.msg || "", - // show details regardless of property name - meta: payload.meta ?? payload.ctx ?? payload.details ?? null - }; - setLogs((prev) => [...prev, normalized]); - }; - - // FINAL step only (emitted by server after rr-finalize-repair-order) - const handleExportSuccess = (payload) => { - const jobId = payload?.jobId ?? payload; // RR sends object; legacy sends raw id - notification.success({ message: t("jobs.successes.exported") }); - setRrCashierPending(false); - insertAuditTrail({ - jobid: jobId, - operation: AuditTrailMapping.jobexported(), - type: "jobexported" - }); - history("/manage/accounting/receivables"); - }; - - // STEP 1 result (RO created) – DO NOT navigate; wait for cashiering - const handleRrExportResult = () => { - // Be defensive: if the server didn't already set the banner yet, make it obvious - setRrCashierPending(true); - setLogs((prev) => [ - ...prev, - { - timestamp: new Date(), - level: "INFO", - message: - "Repair Order created in Reynolds. Complete cashiering in Reynolds, then click Finished/Close to finalize." - } - ]); - notification.info({ - message: "Reynolds RO created", - description: - "Complete cashiering in Reynolds, then click Finished/Close to finalize and mark this export complete.", - duration: 8 - }); - // No routing here — we remain on the page for step 2 - }; - - // NEW: cashier step required (after create, before finalize) - const handleCashieringRequired = (payload) => { - setRrCashierPending(true); - setLogs((prev) => [ - ...prev, - { - timestamp: new Date(), - level: "INFO", - message: - "Repair Order created in Reynolds. Complete cashiering in Reynolds, then click Finished/Close to finalize.", - meta: { payload } - } - ]); - }; - - wsssocket.on("connect", handleConnect); - wsssocket.on("reconnect", handleReconnect); - wsssocket.on("connect_error", handleConnectError); - - // RR channels (over wss) - wsssocket.on("rr-log-event", handleLogEvent); - wsssocket.on("rr-export-job:result", handleRrExportResult); - - wsssocket.on("export-success", handleExportSuccess); - wsssocket.on("export-failed", handleExportFailed); - - // NEW - wsssocket.on("rr-cashiering-required", handleCashieringRequired); - - return () => { - wsssocket.off("connect", handleConnect); - wsssocket.off("reconnect", handleReconnect); - wsssocket.off("connect_error", handleConnectError); - - wsssocket.off("rr-log-event", handleLogEvent); - wsssocket.off("rr-export-job:result", handleRrExportResult); - - wsssocket.off("export-success", handleExportSuccess); - wsssocket.off("export-failed", handleExportFailed); - - wsssocket.off("rr-cashiering-required", handleCashieringRequired); - }; + // Connect legacy socket if needed + if (!isWssMode(mode)) { + if (activeSocket.disconnected) activeSocket.connect(); } - // Fortellis / CDK behavior (when not RR) - if (Fortellis.treatment === "on") { - wsssocket.emit("set-log-level", logLevel); + // Set log level now and on connect/reconnect + setActiveLogLevel(logLevel); - const handleLogEvent = (payload) => setLogs((prev) => [...prev, payload]); - const handleExportSuccess = (payload) => { - notification.success({ message: t("jobs.successes.exported") }); - insertAuditTrail({ - jobid: payload, - operation: AuditTrailMapping.jobexported(), - type: "jobexported" - }); - history("/manage/accounting/receivables"); - }; + const onConnect = () => { + setIsConnected(true); + setActiveLogLevel(logLevel); + }; - // Fortellis logs (wss) - wsssocket.on("fortellis-log-event", handleLogEvent); - wsssocket.on("export-success", handleExportSuccess); - wsssocket.on("export-failed", handleExportFailed); + const onDisconnect = () => setIsConnected(false); - return () => { - wsssocket.off("fortellis-log-event", handleLogEvent); - wsssocket.off("export-success", handleExportSuccess); - wsssocket.off("export-failed", handleExportFailed); - }; - } else { - // CDK/PBS via legacy /ws socket - socket.on("export-failed", handleExportFailed); + const onReconnect = () => { + setIsConnected(true); + setLogs((prev) => [ + ...prev, + { + timestamp: new Date(), + level: "warn", + message: `Reconnected to ${mode === DMS_MAP.reynolds ? "RR" : mode === DMS_MAP.fortellis ? "Fortellis" : "DMS"} Export Service` + } + ]); + }; - socket.on("connect", () => socket.emit("set-log-level", logLevel)); - socket.on("reconnect", () => { - setLogs((prev) => [ - ...prev, - { timestamp: new Date(), level: "warn", message: "Reconnected to CDK Export Service" } - ]); - }); - socket.on("connect_error", (err) => { - console.log(`connect_error due to ${err}`, err); - notification.error({ message: err.message }); - }); - socket.on("log-event", (payload) => setLogs((prev) => [...prev, payload])); - socket.on("export-success", (payload) => { - notification.success({ message: t("jobs.successes.exported") }); - insertAuditTrail({ - jobid: payload, - operation: AuditTrailMapping.jobexported(), - type: "jobexported" - }); - history("/manage/accounting/receivables"); + const onConnectError = (err) => { + // Legacy and WSS both emit this + console.log(`connect_error due to ${err}`, err); + notification.error({ message: err.message }); + }; + + activeSocket.on("disconnect", onDisconnect); + activeSocket.on("connect", onConnect); + activeSocket.on("reconnect", onReconnect); + activeSocket.on("connect_error", onConnectError); + + // Logs + const onLog = + mode === DMS_MAP.reynolds + ? (payload = {}) => { + const normalized = { + timestamp: payload.timestamp + ? new Date(payload.timestamp) + : payload.ts + ? new Date(payload.ts) + : new Date(), + level: (payload.level || "INFO").toUpperCase(), + message: payload.message || payload.msg || "", + meta: payload.meta ?? payload.ctx ?? payload.details ?? null + }; + setLogs((prev) => [...prev, normalized]); + } + : (payload) => setLogs((prev) => [...prev, payload]); + + if (channels.log) activeSocket.on(channels.log, onLog); + + // Success / Failed + const onExportSuccess = (payload) => { + const jobIdResolved = payload?.jobId ?? payload; + notification.success({ message: t("jobs.successes.exported") }); + + // Clear RR cashier flag if any + setRrCashierPending(false); + + insertAuditTrail({ + jobid: jobIdResolved, + operation: AuditTrailMapping.jobexported(), + type: "jobexported" }); + history("/manage/accounting/receivables"); + }; - if (socket.disconnected) socket.connect(); - return () => { - socket.removeAllListeners(); - socket.disconnect(); - }; - } - }, [dms, Fortellis?.treatment, logLevel, history, insertAuditTrail, notification, t, wsssocket]); + if (channels.exportSuccess) activeSocket.on(channels.exportSuccess, onExportSuccess); + if (channels.exportFailed) activeSocket.on(channels.exportFailed, handleExportFailed); - // NEW: finalize button callback—emit finalize event + // RR-only extras + + const onPartialResult = () => { + setRrCashierPending(true); + setLogs((prev) => [ + ...prev, + { + timestamp: new Date(), + level: "INFO", + message: + "Repair Order created in Reynolds. Complete cashiering in Reynolds, then click Finished/Close to finalize." + } + ]); + notification.info({ + message: "Reynolds RO created", + description: + "Complete cashiering in Reynolds, then click Finished/Close to finalize and mark this export complete.", + duration: 8 + }); + }; + + const onCashierRequired = (payload) => { + setRrCashierPending(true); + setLogs((prev) => [ + ...prev, + { + timestamp: new Date(), + level: "INFO", + message: + "Repair Order created in Reynolds. Complete cashiering in Reynolds, then click Finished/Close to finalize.", + meta: { payload } + } + ]); + }; + + if (mode === DMS_MAP.reynolds && channels.partialResult) activeSocket.on(channels.partialResult, onPartialResult); + if (mode === DMS_MAP.reynolds && channels.cashierNeeded) activeSocket.on(channels.cashierNeeded, onCashierRequired); + + return () => { + activeSocket.off("connect", onConnect); + activeSocket.off("reconnect", onReconnect); + activeSocket.off("connect_error", onConnectError); + activeSocket.off("disconnect", onDisconnect); + + if (channels.log) activeSocket.off(channels.log, onLog); + if (channels.exportSuccess) activeSocket.off(channels.exportSuccess, onExportSuccess); + if (channels.exportFailed) activeSocket.off(channels.exportFailed, handleExportFailed); + + if (mode === DMS_MAP.reynolds && channels.partialResult) + activeSocket.off(channels.partialResult, onPartialResult); + if (mode === DMS_MAP.reynolds && channels.cashierNeeded) + activeSocket.off(channels.cashierNeeded, onCashierRequired); + + // Only tear down legacy socket listeners; don't disconnect WSS from here + if (!isWssMode(mode)) { + activeSocket.removeAllListeners(); + activeSocket.disconnect(); + } + }; + }, [mode, activeSocket, channels, logLevel, notification, t, insertAuditTrail, history]); + + // RR finalize callback (unchanged public behavior) const handleRrCashierFinished = () => { if (!jobId) return; - wsssocket.emit("rr-finalize-repair-order", { jobId }, (ack) => { - if (ack?.ok) { - // success path handled by export-success listener - return; - } - if (ack?.error) { - notification.error({ message: ack.error }); - } + if (!isWssMode(mode)) return; // RR is WSS-only + activeSocket.emit("rr-finalize-repair-order", { jobId }, (ack) => { + if (ack?.ok) return; + if (ack?.error) notification.error({ message: ack.error }); }); }; if (loading) return ; if (error) return ; - if (!jobId || !(bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid) || !data?.jobs_by_pk) + if (!jobId || !bodyshopHasDmsKey(bodyshop) || !data?.jobs_by_pk) return ; if (data.jobs_by_pk?.date_exported) return ; return (
- +
- {`${ - data?.jobs_by_pk && data.jobs_by_pk.ro_number - }`} - {` | ${OwnerNameDisplayFunction(data.jobs_by_pk)} | ${ - data.jobs_by_pk.v_model_yr || "" - } ${data.jobs_by_pk.v_make_desc || ""} ${data.jobs_by_pk.v_model_desc || ""}`} + {`${data?.jobs_by_pk && data.jobs_by_pk.ro_number}`} + {` | ${OwnerNameDisplayFunction(data.jobs_by_pk)} | ${data.jobs_by_pk.v_model_yr || ""} ${data.jobs_by_pk.v_make_desc || ""} ${data.jobs_by_pk.v_model_desc || ""}`} } socket={activeSocket} jobId={jobId} + mode={mode} /> - + - {/* NEW props for two-step RR flow banners */} @@ -415,11 +408,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse value={logLevel} onChange={(value) => { setLogLevel(value); - if (dms === "rr" || Fortellis.treatment === "on") { - wsssocket.emit("set-log-level", value); - } else { - socket.emit("set-log-level", value); - } + setActiveLogLevel(value); }} > DEBUG @@ -431,11 +420,11 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse @@ -311,7 +314,7 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set )} - {(bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid) && ( + {hasDMSKey && ( )} - {(bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid) && ( + {hasDMSKey && ( )} - {(bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid) && ( + {hasDMSKey && ( { - const dmsMapping = { - cdk_dealerid: "cdk", - pbs_serialnumber: "pbs", - rr_dealerid: "rr" - }; - - return Object.keys(dmsMapping).find((key) => bodyshop[key]) - ? dmsMapping[Object.keys(dmsMapping).find((key) => bodyshop[key])] - : "pbs"; -}; diff --git a/client/src/utils/dmsUtils.js b/client/src/utils/dmsUtils.js new file mode 100644 index 000000000..e4ae68f03 --- /dev/null +++ b/client/src/utils/dmsUtils.js @@ -0,0 +1,72 @@ +/** + * DMS type mapping constants. + * CAREFUL: the values here are used as canonical "mode" strings elsewhere in the app. + * @type {{reynolds: string, cdk: string, pbs: string, fortellis: string}} + */ +export const DMS_MAP = { + reynolds: "rr", + cdk: "cdk", + pbs: "pbs", + fortellis: "fortellis" +}; + +/** + * Determines the DMS type for a given bodyshop object. + * @param bodyshop + * @returns {*|string} + */ +export const determineDMSTypeByBodyshop = (bodyshop) => { + const dmsMapping = { + cdk_dealerid: DMS_MAP.cdk, + pbs_serialnumber: DMS_MAP.pbs, + rr_dealerid: DMS_MAP.reynolds + }; + + return Object.keys(dmsMapping).find((key) => bodyshop[key]) + ? dmsMapping[Object.keys(dmsMapping).find((key) => bodyshop[key])] + : DMS_MAP.pbs; +}; + +/** + * Determines the translation key for a given DMS type. + * @param dmsType + * @returns {*|string} + */ +export const determineDmsTypeTranslationKey = (dmsType) => { + const dmsTypeMapping = { + [DMS_MAP.cdk]: "bodyshop.labels.dms.cdk", + [DMS_MAP.pbs]: "bodyshop.labels.dms.pbs", + [DMS_MAP.reynolds]: "bodyshop.labels.dms.rr" + }; + + return dmsTypeMapping[dmsType] || dmsTypeMapping[DMS_MAP.pbs]; +}; + +/** + * Returns a normalized "mode" we can switch on: + * @param bodyshop + * @param fortellisTreatment + * @returns {*|string|string} + */ +export const getDmsMode = (bodyshop, fortellisTreatment) => { + const base = determineDMSTypeByBodyshop(bodyshop); // "rr" | "cdk" | "pbs" | undefined + if (base === DMS_MAP.cdk && fortellisTreatment === "on") return DMS_MAP.fortellis; + return base ?? "none"; +}; + +/** + * Checks if the DMS mode uses WSS. + * @param mode + * @returns {boolean} + */ +export const isWssMode = (mode) => { + return mode === DMS_MAP.reynolds || mode === DMS_MAP.fortellis; +}; + +/** + * Checks if the bodyshop has any DMS key configured. + * @param bodyshop + * @returns {*|string} + */ +export const bodyshopHasDmsKey = (bodyshop) => + bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid; diff --git a/client/src/utils/legacySocket.js b/client/src/utils/legacySocket.js new file mode 100644 index 000000000..c20f1cb6e --- /dev/null +++ b/client/src/utils/legacySocket.js @@ -0,0 +1,16 @@ +// client/src/utils/legacySocket.js +import SocketIO from "socket.io-client"; +import { auth } from "../firebase/firebase.utils"; + +// Create once, reuse everywhere. +const legacySocket = SocketIO(import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "", { + path: "/ws", + withCredentials: true, + autoConnect: false, + auth: async (callback) => { + const token = auth.currentUser && (await auth.currentUser.getIdToken()); + callback({ token }); + } +}); + +export default legacySocket;