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.
This commit is contained in:
@@ -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) => <Checkbox disabled checked={r.vinOwner} />
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<Col span={24}>
|
||||
<Table
|
||||
title={() => (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<Button onClick={onUseSelected} disabled={!selectedCustomer}>
|
||||
{t("jobs.actions.dms.useselected")}
|
||||
</Button>
|
||||
<Button onClick={onUseGeneric} disabled={!bodyshop.cdk_configuration?.generic_customer_number}>
|
||||
{t("jobs.actions.dms.usegeneric")}
|
||||
</Button>
|
||||
<Button onClick={onCreateNew}>{t("jobs.actions.dms.createnewcustomer")}</Button>
|
||||
</div>
|
||||
)}
|
||||
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] : []
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
@@ -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) => <Checkbox disabled checked={r.vinOwner} />
|
||||
},
|
||||
{
|
||||
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) => <Checkbox disabled checked={r.vinOwner} />
|
||||
},
|
||||
{
|
||||
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) => <Checkbox disabled checked={!!(r.vinOwner ?? r.isVehicleOwner)} />
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<Col span={24}>
|
||||
<Table
|
||||
title={() => (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{/* Open RO limit banner (from parent flag) */}
|
||||
{dmsType === "rr" && rrOpenRoLimit && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="Open RO limit reached in Reynolds"
|
||||
description={
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div>
|
||||
Reynolds has reached the maximum number of open Repair Orders for this Customer. Close or finalize
|
||||
an RO in Reynolds, then click <strong>Finished</strong> to continue.
|
||||
</div>
|
||||
<div>
|
||||
<Button type="primary" danger onClick={onRrOpenRoFinished}>
|
||||
Finished
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* NEW: Cashiering required banner */}
|
||||
{dmsType === "rr" && rrCashierPending && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="Complete cashiering in Reynolds"
|
||||
description={
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div>
|
||||
We created the Repair Order in Reynolds. Please complete the cashiering/closeout steps in
|
||||
Reynolds. When done, click <strong>Finished/Close</strong> to finalize and mark this export as
|
||||
complete.
|
||||
</div>
|
||||
<div>
|
||||
<Space>
|
||||
<Button type="primary" onClick={onRrCashierFinished}>
|
||||
Finished / Close
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<Button onClick={onUseSelected} disabled={!selectedCustomer || (dmsType === "rr" && rrOpenRoLimit)}>
|
||||
{t("jobs.actions.dms.useselected")}
|
||||
</Button>
|
||||
|
||||
{/* Hide "Use Generic" entirely in RR mode */}
|
||||
{dmsType !== "rr" && (
|
||||
<Button onClick={onUseGeneric} disabled={!bodyshop.cdk_configuration?.generic_customer_number}>
|
||||
{t("jobs.actions.dms.usegeneric")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button onClick={onCreateNew} disabled={dmsType === "rr" ? rrHasVinOwner : false}>
|
||||
{t("jobs.actions.dms.createnewcustomer")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* VIN ownership enforced with Refresh */}
|
||||
{dmsType === "rr" && rrHasVinOwner && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="VIN ownership enforced"
|
||||
description={
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 }}>
|
||||
<div>
|
||||
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.
|
||||
</div>
|
||||
<Button onClick={refreshRrSearch} loading={refreshing}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
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)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
// 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 <RRCustomerSelector {...base} {...rrProps} />;
|
||||
}
|
||||
case DMS_MAP.fortellis:
|
||||
return <FortellisCustomerSelector {...base} />;
|
||||
case DMS_MAP.cdk:
|
||||
return <CDKCustomerSelector {...base} />;
|
||||
case DMS_MAP.pbs:
|
||||
return <PBSCustomerSelector {...base} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => <Checkbox disabled checked={r.vinOwner} />
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<Col span={24}>
|
||||
<Table
|
||||
title={() => (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<Button onClick={onUseSelected} disabled={!selectedCustomer}>
|
||||
{t("jobs.actions.dms.useselected")}
|
||||
</Button>
|
||||
<Button onClick={onUseGeneric} disabled={!bodyshop.cdk_configuration?.generic_customer_number}>
|
||||
{t("jobs.actions.dms.usegeneric")}
|
||||
</Button>
|
||||
<Button onClick={onCreateNew}>{t("jobs.actions.dms.createnewcustomer")}</Button>
|
||||
</div>
|
||||
)}
|
||||
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] : []
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Col span={24}>
|
||||
<Table
|
||||
title={() => (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<Button onClick={onUseSelected} disabled={!selectedCustomer}>
|
||||
{t("jobs.actions.dms.useselected")}
|
||||
</Button>
|
||||
<Button onClick={onUseGeneric} disabled={!hasGeneric}>
|
||||
{t("jobs.actions.dms.usegeneric")}
|
||||
</Button>
|
||||
<Button onClick={onCreateNew}>{t("jobs.actions.dms.createnewcustomer")}</Button>
|
||||
</div>
|
||||
)}
|
||||
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] : []
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
@@ -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) => <Checkbox disabled checked={!!(r.vinOwner ?? r.isVehicleOwner)} />
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<Col span={24}>
|
||||
<Table
|
||||
title={() => (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{/* Open RO limit banner */}
|
||||
{rrOpenRoLimit && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="Open RO limit reached in Reynolds"
|
||||
description={
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div>
|
||||
Reynolds has reached the maximum number of open Repair Orders for this Customer. Close or finalize
|
||||
an RO in Reynolds, then click <strong>Finished</strong> to continue.
|
||||
</div>
|
||||
<div>
|
||||
<Button type="primary" danger onClick={onRrOpenRoFinished}>
|
||||
Finished
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Cashiering step banner */}
|
||||
{rrCashierPending && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="Complete cashiering in Reynolds"
|
||||
description={
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div>
|
||||
We created the Repair Order in Reynolds. Please complete the cashiering/closeout steps in
|
||||
Reynolds. When done, click <strong>Finished/Close</strong> to finalize and mark this export as
|
||||
complete.
|
||||
</div>
|
||||
<div>
|
||||
<Space>
|
||||
<Button type="primary" onClick={onRrCashierFinished}>
|
||||
Finished / Close
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<Button onClick={onUseSelected} disabled={!selectedCustomer || rrOpenRoLimit}>
|
||||
{t("jobs.actions.dms.useselected")}
|
||||
</Button>
|
||||
{/* No generic in RR */}
|
||||
<Button onClick={onCreateNew} disabled={rrHasVinOwner}>
|
||||
{t("jobs.actions.dms.createnewcustomer")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{rrHasVinOwner && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="VIN ownership enforced"
|
||||
description={
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 }}>
|
||||
<div>
|
||||
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.
|
||||
</div>
|
||||
<Button onClick={refreshRrSearch} loading={refreshing}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
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) })
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user