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 f881e9fbd..547dfcba1 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 @@ -50,14 +50,11 @@ export function DmsAllocationsSummary({ socket, bodyshop, jobId, title }) { setAllocationsSummary(ack); socket.allocationsSummary = ack; }); - } else { - // Default to CDK path - if (socket.connected) { - socket.emit("cdk-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; + }); } }; 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 b0a2b21b6..e5927f9de 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,5 +1,5 @@ import { useSplitTreatments } from "@splitsoftware/splitio-react"; -import { Button, Checkbox, Col, Table } from "antd"; +import { Button, Checkbox, Col, Form, Input, message, Modal, Table } from "antd"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; @@ -22,6 +22,8 @@ export function DmsCustomerSelector({ bodyshop, jobid }) { const [open, setOpen] = useState(false); const [selectedCustomer, setSelectedCustomer] = useState(null); const [dmsType, setDmsType] = useState("cdk"); + const [openCreate, setOpenCreate] = useState(false); + const [createForm] = Form.useForm(); const { treatments: { Fortellis } @@ -44,9 +46,31 @@ export function DmsCustomerSelector({ bodyshop, jobid }) { setDmsType("rr"); setcustomerList(Array.isArray(list) ? list : []); }; + + const handleRrCreateRequired = () => { + // Open selector shell + creation form + setOpen(true); + setDmsType("rr"); + setcustomerList([]); + setOpenCreate(true); + }; + const handleRrCustomerCreated = ({ custNo }) => { + if (custNo) { + message.success(t("dms.messages.customerCreated")); + setSelectedCustomer(custNo); + setOpenCreate(false); + setOpen(false); + } + }; + + wsssocket.on("rr-customer-create-required", handleRrCreateRequired); + wsssocket.on("rr-customer-created", handleRrCustomerCreated); wsssocket.on("rr-select-customer", handleRrSelectCustomer); + return () => { wsssocket.off("rr-select-customer", handleRrSelectCustomer); + wsssocket.off("rr-customer-create-required", handleRrCreateRequired); + wsssocket.off("rr-customer-created", handleRrCustomerCreated); }; } @@ -81,13 +105,18 @@ export function DmsCustomerSelector({ bodyshop, jobid }) { }, [dms, Fortellis?.treatment, wsssocket]); const onUseSelected = () => { - setOpen(false); if (dmsType === "rr") { - wsssocket.emit("rr-selected-customer", { bodyshopId, custNo: selectedCustomer, jobId: jobid }); - } else if (Fortellis.treatment === "on") { - wsssocket.emit("fortellis-selected-customer", { selectedCustomerId: selectedCustomer, jobid }); + // Stay open and show creation form + setOpen(true); + setOpenCreate(true); + return; + } + // Non-RR behavior unchanged: + setOpen(false); + if (Fortellis.treatment === "on") { + wsssocket.emit("fortellis-selected-customer", { selectedCustomerId: null, jobid }); } else { - socket.emit(`${dmsType}-selected-customer`, selectedCustomer); + socket.emit(`${dmsType}-selected-customer`, null); } setSelectedCustomer(null); }; @@ -264,6 +293,66 @@ export function DmsCustomerSelector({ bodyshop, jobid }) { selectedRowKeys: [selectedCustomer] }} /> + setOpenCreate(false)} + onOk={() => createForm.submit()} + destroyOnClose + > +
{ + // Map a few sane defaults; BE tolerates partials + const fields = { + FirstName: values.FirstName, + LastName: values.LastName, + CompanyName: values.CompanyName, + Phone: values.Phone, + AddressLine1: values.AddressLine1, + City: values.City, + StateProvince: values.StateProvince, + PostalCode: values.PostalCode + }; + wsssocket.emit("rr-create-customer", { jobId: jobid, fields }, (ack) => { + if (ack?.ok) { + message.success(t("dms.messages.customerCreated")); + setSelectedCustomer(ack.custNo); + setOpenCreate(false); + setOpen(false); + } else { + message.error(ack?.error || t("general.errors.unknown")); + } + }); + }} + > + + + + + + + + + + + + + + + + + + + + + + + + +
+
); } diff --git a/server/rr/rr-job-helpers.js b/server/rr/rr-job-helpers.js index 85d678e8c..11dbdd8cc 100644 --- a/server/rr/rr-job-helpers.js +++ b/server/rr/rr-job-helpers.js @@ -15,7 +15,9 @@ async function QueryJobData(ctx = {}, jobId) { try { const hit = await redisHelpers.getJobFromCache(jobId); if (hit) return hit; - } catch {} + } catch { + // + } } if (typeof redisHelpers.fetchJobById === "function") { const full = await redisHelpers.fetchJobById(jobId); diff --git a/server/web-sockets/rr-register-socket-events.js b/server/web-sockets/rr-register-socket-events.js index 0edd34049..734ec41cc 100644 --- a/server/web-sockets/rr-register-socket-events.js +++ b/server/web-sockets/rr-register-socket-events.js @@ -16,6 +16,61 @@ function resolveJobId(explicit, payload, job) { return explicit || payload?.jobId || payload?.jobid || job?.id || job?.jobId || job?.jobid || null; } +// ---- local helpers (avoid no-undef) ---- +const digitsOnly = (s) => String(s || "").replace(/\D/g, ""); + +const makeVehicleSearchPayloadFromJob = (job) => { + const vin = job?.v_vin || job?.vehicle?.vin || job?.vehicle_vin || job?.vin; + if (vin) return { kind: "vin", vin: String(vin).trim() }; + const plate = job?.plate_no || job?.vehicle?.plate || job?.vehicle_plate || job?.plate; + if (plate) return { kind: "license", license: String(plate).trim() }; + return null; +}; + +const makeCustomerSearchPayloadFromJob = (job) => { + const phone = job?.ownr_ph1 || job?.customer?.mobile || job?.customer?.home_phone || job?.customer?.phone; + const d = digitsOnly(phone); + if (d.length >= 7) return { kind: "phone", phone: d }; + + const lastName = job?.ownr_ln || job?.customer?.last_name || job?.customer?.lastName; + const company = job?.ownr_co_nm || job?.customer?.company_name || job?.customer?.companyName; + const lnOrCompany = lastName || company; + if (lnOrCompany) return { kind: "name", name: { name: String(lnOrCompany).trim() } }; + + const vin = job?.v_vin || job?.vehicle?.vin || job?.vehicle_vin || job?.vin; + if (vin) return { kind: "vin", vin: String(vin).trim() }; + + return null; +}; + +const normalizeCustomerCandidates = (res) => { + // CombinedSearch may return array or { data: [...] } + const blocks = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : []; + const out = []; + for (const blk of blocks) { + const serv = Array.isArray(blk?.ServVehicle) ? blk.ServVehicle : []; + const custNos = serv.map((sv) => sv?.VehicleServInfo?.CustomerNo).filter(Boolean); + + const nci = blk?.NameContactId; + const ind = nci?.NameId?.IndName; + const bus = nci?.NameId?.BusName; + const personal = [ind?.FName, ind?.LName].filter(Boolean).join(" ").trim(); + const company = bus?.CompanyName; + const name = (personal || company || "").trim(); + + for (const custNo of custNos) { + out.push({ custNo, name: name || `Customer ${custNo}` }); + } + } + const seen = new Set(); + return out.filter((c) => { + const key = String(c.custNo || "").trim(); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); +}; + async function getSessionOrSocket(redisHelpers, socket) { let sess = null; try { @@ -177,33 +232,34 @@ function registerRREvents({ socket, redisHelpers }) { } }); - // Export flow // Export flow socket.on("rr-export-job", async (payload = {}) => { try { - // Extract job / ids + // -------- 1) Resolve job -------- let job = payload.job || payload.txEnvelope?.job; const jobId = payload.jobId || payload.jobid || payload.txEnvelope?.jobId || job?.id; if (!job) { if (!jobId) throw new Error("RR export: job or jobId required"); - // Fetch full job when only jobId is provided job = await QueryJobData({ redisHelpers }, jobId); } - // Resolve bodyshop id + // -------- 2) Resolve bodyshop (+ full row via GraphQL) -------- let bodyshopId = payload.bodyshopId || payload.bodyshopid || payload.bodyshopUUID || job?.bodyshop?.id; if (!bodyshopId) { - const { bodyshopId: sid } = await getSessionOrSocket(redisHelpers, socket); - bodyshopId = sid; + const sess = await getSessionOrSocket(redisHelpers, socket); + bodyshopId = sess.bodyshopId; } if (!bodyshopId) throw new Error("RR export: bodyshopId required"); - // Load authoritative bodyshop row (so rr-config can read routing) - let bodyshop = job?.bodyshop || (await getBodyshopForSocket({ bodyshopId, socket })) || { id: bodyshopId }; + // Authoritative bodyshop row for RR routing (env fallback handled downstream) + let bodyshop = + job?.bodyshop && (job.bodyshop.rr_dealerid || job.bodyshop.rr_configuration) + ? job.bodyshop + : await getBodyshopForSocket({ bodyshopId, socket }); - // Optional FE routing override (safe: routing only) + // Optional FE routing override (kept minimal/safe) const feRouting = payload.rrRouting; if (feRouting) { const cfg = bodyshop.rr_configuration || {}; @@ -219,40 +275,93 @@ function registerRREvents({ socket, redisHelpers }) { }; } - // Selected customer resolution (tx → payload → create) + // -------- 3) Resolve selected customer (payload → tx) -------- const tx = (await redisHelpers.getSessionTransactionData(socket.id)) || {}; let selectedCustomer = null; // from payload if (payload.selectedCustomer) { if (typeof payload.selectedCustomer === "object" && payload.selectedCustomer.custNo) { - selectedCustomer = { custNo: payload.selectedCustomer.custNo }; + selectedCustomer = { custNo: String(payload.selectedCustomer.custNo) }; } else if (typeof payload.selectedCustomer === "string") { - selectedCustomer = { custNo: payload.selectedCustomer }; + selectedCustomer = { custNo: String(payload.selectedCustomer) }; } } - // from tx if still not set + // from tx if (!selectedCustomer && tx.rrSelectedCustomer) { if (typeof tx.rrSelectedCustomer === "object" && tx.rrSelectedCustomer.custNo) { - selectedCustomer = { custNo: tx.rrSelectedCustomer.custNo }; + selectedCustomer = { custNo: String(tx.rrSelectedCustomer.custNo) }; } else { - selectedCustomer = { custNo: tx.rrSelectedCustomer }; + selectedCustomer = { custNo: String(tx.rrSelectedCustomer) }; } } - // create on demand (flagged or missing) - if (!selectedCustomer || tx.rrCreateCustomer === true) { - const created = await createRRCustomer({ bodyshop, job, socket }); - selectedCustomer = { custNo: created.custNo }; - await redisHelpers.setSessionTransactionData(socket.id, { - ...tx, - rrSelectedCustomer: created.custNo, - rrCreateCustomer: false - }); - log("info", "rr-export-job:customer-created", { jobId, custNo: created.custNo }); + const forceCreate = payload.forceCreate === true || tx.rrCreateCustomer === true; + + // -------- 4) If no selection & not "forceCreate", try auto-search then ask UI to pick -------- + if (!selectedCustomer && !forceCreate) { + const customerQuery = makeCustomerSearchPayloadFromJob(job); + const vehicleQuery = makeVehicleSearchPayloadFromJob(job); + const query = customerQuery || vehicleQuery; + + if (query) { + log("info", "rr-export-job:customer-preselect-search", { query, jobId }); + const searchRes = await rrCombinedSearch(bodyshop, query); + const candidates = normalizeCustomerCandidates(searchRes); + + if (candidates.length === 1) { + // auto-pick single hit + selectedCustomer = { custNo: String(candidates[0].custNo) }; + await redisHelpers.setSessionTransactionData(socket.id, { + ...tx, + rrSelectedCustomer: selectedCustomer.custNo, + rrCreateCustomer: false + }); + log("info", "rr-export-job:auto-selected-customer", { jobId, custNo: selectedCustomer.custNo }); + } else if (candidates.length > 1) { + // emit picker data and stop; UI will call rr-selected-customer next + const table = candidates.map((c) => ({ + CustomerId: c.custNo, + customerId: c.custNo, + CustomerName: { FirstName: c.name || String(c.custNo), LastName: "" } + })); + socket.emit("rr-select-customer", table); + socket.emit("rr-log-event", { level: "info", message: "RR: customer selection required", ts: Date.now() }); + return; // wait for user selection + } + } + + // no query or no matches → ask UI to create + if (!selectedCustomer) { + await redisHelpers.setSessionTransactionData(socket.id, { ...tx, rrCreateCustomer: true }); + socket.emit("rr-customer-create-required"); + socket.emit("rr-log-event", { level: "info", message: "RR: create customer required", ts: Date.now() }); + return; + } } + // -------- 5) If still not selected & creation is allowed, create now -------- + if (!selectedCustomer && forceCreate) { + const { createRRCustomer } = require("../rr/rr-customers"); + const created = await createRRCustomer({ bodyshop, job, socket }); + // fallback when API returns only DMSRecKey + const custNo = created?.custNo || created?.customerNo || created?.CustomerNo || created?.dmsRecKey; + + if (!custNo) throw new Error("RR create customer returned no custNo"); + + selectedCustomer = { custNo: String(custNo) }; + await redisHelpers.setSessionTransactionData(socket.id, { + ...tx, + rrSelectedCustomer: selectedCustomer.custNo, + rrCreateCustomer: false + }); + log("info", "rr-export-job:customer-created", { jobId, custNo: selectedCustomer.custNo }); + } + + if (!selectedCustomer?.custNo) throw new Error("RR export: selected customer missing custNo"); + + // -------- 6) Perform export -------- const advisorNo = payload.advisorNo || payload.advNo || tx.rrAdvisorNo; const options = payload.options || payload.txEnvelope?.options || {}; @@ -284,7 +393,7 @@ function registerRREvents({ socket, redisHelpers }) { try { socket.emit("export-failed", { vendor: "rr", jobId, error: error.message }); } catch { - // ignore + // } } });