/* eslint-disable */ /* © 2026 K2B NETWORK SOLUTIONS LLC. All rights reserved. Proprietary and confidential. Unauthorized copying, distribution, or use of this file, via any medium, is strictly prohibited. Original work product. */ // JBREWS DMS — Customers (CUSTLOOK). Directory + detail. // Lists every customer stored in JBREWS. Customer # = the phone WITHOUT the area code (7 digits) — RC30 (cn), CustNum.jsx.txt == the server's rule; // a household (two people, one phone) is ONE account with every name on it; an old number (the 10-digit phone before RC30) still finds the account. // ONE PATH: customers are CREATED in the CRM (+ New Lead → New Customer / // Scan DL). This screen browses them, fixes contact details, imports // (managers), deletes (admin) and hands a customer back to the CRM // ("Start Lead in CRM"). // Sales features (lane 1): search by name / phone (any format) / email / VIN / // vehicle / co-buyer / "sold by"; Sold by + Sold on + Owner columns; My // customers / Orphans / In service filters; the owner box (claim clock, log // contact, release, manager reassign). Whose customer is whose comes from the // server (GET /api/customer-owners/summary). For Sales the server never sends // SSN, birth date, licence, credit or income; this screen also hides those // blocks for them. Names new to this file are prefixed custSls. const CUST_KEY = "jbrews-customers"; // Legacy migration: pull any customers saved under the old standalone key into // the shared ledger once, so nothing is lost from earlier sessions. const migrateLegacyCustomers = () => { try { const raw = localStorage.getItem(CUST_KEY); if (!raw || !window.DMS) return; const map = JSON.parse(raw); Object.values(map).forEach(c => { if (c && c.custNum) window.DMS.upsertCustomer(c); }); localStorage.removeItem(CUST_KEY); } catch (e) {} }; if (typeof window !== "undefined") setTimeout(migrateLegacyCustomers, 0); /* ── small helpers ── */ const custSlsDigits = (p) => String(p == null ? "" : p).replace(/\D/g, ""); const custSlsPhone10 = (p) => { let d = custSlsDigits(p); if (d.length === 11 && d[0] === "1") d = d.slice(1); return d.length === 10 ? d : ""; }; const custSlsFmtPhone = (p) => { const d = custSlsPhone10(p); return d ? "(" + d.slice(0, 3) + ") " + d.slice(3, 6) + "-" + d.slice(6) : String(p || ""); }; const custSlsNameKey = (s) => String(s || "").toUpperCase().replace(/[.,'"]/g, " ").split(/\s+/).filter(Boolean).sort().join(" "); const CUST_SLS_PHONE_FIELDS = ["phone", "cell", "mobile", "homePhone", "workPhone", "home", "work"]; const CUST_SLS_MON = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; // a date-only "2023-05-10" → "May 10, 2023" (built from its parts: never new Date("YYYY-MM-DD")) const custSlsFmtDate = (s) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(s || "")); return m ? CUST_SLS_MON[+m[2] - 1] + " " + (+m[3]) + ", " + m[1] : ""; }; const custSlsSoldOn = (d) => { const v = d.delivered || d.date || (d.imported ? "" : d.created); const m = /^\d{4}-\d{2}-\d{2}/.exec(String(v || "")); return m ? m[0] : ""; }; const custSlsVehicleOf = (d) => String(d.vehicle || [d.yr || d.year, d.mk || d.make, d.mdl || d.model].filter(Boolean).join(" ") || "").trim(); const custSlsCoBuyerOf = (d) => { const cb = d.creditApp && d.creditApp.coBuyer; const fromApp = cb ? [cb.firstName, cb.lastName].filter(Boolean).join(" ") : ""; return String(d.coBuyerName || (typeof d.cobuyer === "string" ? d.cobuyer : "") || fromApp || "").trim(); }; const custSlsMyId = () => { try { const ti = window.DMS && DMS.tokenInfo && DMS.tokenInfo(); return ti && ti.sub ? String(ti.sub) : ""; } catch (e) { return ""; } }; const custSlsToast = (m, k, ms) => { try { window.k2bToast && window.k2bToast(m, k || "ok", ms || 3600); } catch (e) {} }; const custSlsApi = (path, opts) => (typeof apiFetch === "function" ? apiFetch(path, opts || {}) : Promise.reject(Object.assign(new Error("Not connected to the server."), { status: 0 }))); const custSlsErr = (e, fb) => (e && e.body && e.body.error) || (e && e.status && e.message) || fb; /* ── whose customer is whose: GET /api/customer-owners/summary (shared cache, ETag) ── */ const custSlsSum = { tok: null, me: undefined, meP: null, etag: null, customers: {}, loaded: false, version: 0, listeners: new Set(), pending: new Set(), pendTimer: null, iv: null, wsH: null, localH: null }; const custSlsEmit = () => { custSlsSum.version++; custSlsSum.listeners.forEach((fn) => { try { fn(custSlsSum.version); } catch (e) {} }); }; const custSlsTok = () => { try { return (window.DMS && DMS.getAuthToken && DMS.getAuthToken()) || ""; } catch (e) { return ""; } }; function custSlsCheckTok() { const t = custSlsTok(); if (t !== custSlsSum.tok) { custSlsSum.tok = t; custSlsSum.me = undefined; custSlsSum.meP = null; custSlsSum.etag = null; custSlsSum.customers = {}; custSlsSum.loaded = false; custSlsSum.pending = new Set(); } return t; } // The feature flags (GET /sales-alerts/me); null = the server has no sales features. function custSlsGetMe() { const t = custSlsCheckTok(); if (!t) { custSlsSum.me = null; return Promise.resolve(null); } if (custSlsSum.me !== undefined) return Promise.resolve(custSlsSum.me); if (custSlsSum.meP) return custSlsSum.meP; const p = (window.slsGetMe ? window.slsGetMe() : custSlsApi("/sales-alerts/me").then((r) => (r && r.ok ? r : null), () => null)) .then((m) => { if (t === custSlsSum.tok) { custSlsSum.me = m || null; custSlsSum.meP = null; custSlsEmit(); } return m || null; }); custSlsSum.meP = p; return p; } // The full summary with If-None-Match (apiFetch hides response headers, so this one call reads them itself). async function custSlsLoadFull() { const t = custSlsCheckTok(); if (!t || !custSlsSum.me || ["sales", "manager"].indexOf(custSlsSum.me.roleClass) < 0) return; try { const headers = { Authorization: "Bearer " + t }; try { const s = window.DMS && DMS.session && DMS.session(); if (s && s.sessionId) headers["X-Session-Id"] = s.sessionId; } catch (e) {} if (custSlsSum.etag) headers["If-None-Match"] = custSlsSum.etag; try { window.K2BStore && window.K2BStore.stamp(headers); } catch (e) {} // RC35 (brd): the page's store + protocol on every request (SPEC 4.1) const res = await fetch(location.origin + "/api/customer-owners/summary", { headers, credentials: "omit" }); if (t !== custSlsSum.tok) return; if (res.status === 304) { if (!custSlsSum.loaded) { custSlsSum.loaded = true; custSlsEmit(); } return; } if (res.status === 401) { custSlsApi("/customer-owners/summary").catch(() => {}); return; } // apiFetch's signed-out handling if (!res.ok) return; const j = await res.json(); if (t !== custSlsSum.tok) return; custSlsSum.customers = (j && j.customers) || {}; custSlsSum.etag = res.headers.get("ETag") || null; custSlsSum.loaded = true; custSlsEmit(); } catch (e) { /* keep the last copy; next refresh retries */ } } // Only these customers (after a claim, a release, a reassignment, a name match …). async function custSlsLoadKeys(keys) { const t = custSlsCheckTok(); if (!t || !custSlsSum.me || ["sales", "manager"].indexOf(custSlsSum.me.roleClass) < 0 || !keys.length) return; for (let i = 0; i < keys.length; i += 200) { const part = keys.slice(i, i + 200); try { const r = await custSlsApi("/customer-owners/summary?custKeys=" + encodeURIComponent(part.join(","))); if (t !== custSlsSum.tok) return; const next = Object.assign({}, custSlsSum.customers); for (const k of part) { const v = r && r.customers && r.customers[k]; if (v && (v.status !== "none" || (v.ownerIds || []).length)) next[k] = v; else delete next[k]; } custSlsSum.customers = next; custSlsSum.etag = null; custSlsEmit(); } catch (e) {} } } function custSlsQueue(keys, ms) { (keys || []).forEach((k) => { if (k) custSlsSum.pending.add(String(k)); }); if (custSlsSum.pendTimer) clearTimeout(custSlsSum.pendTimer); custSlsSum.pendTimer = setTimeout(() => { custSlsSum.pendTimer = null; const ks = Array.from(custSlsSum.pending); custSlsSum.pending = new Set(); if (ks.length) custSlsLoadKeys(ks); else custSlsLoadFull(); }, ms == null ? 2000 : ms); } function custSlsStart() { if (custSlsSum.iv) return; custSlsSum.wsH = (e) => { const m = e && e.detail; if (m && m.type === "sales_owner") custSlsQueue(m.custKeys || [], 2000); }; custSlsSum.localH = (e) => { const ks = (e && e.detail && e.detail.custKeys) || []; custSlsQueue(ks, 300); }; window.addEventListener("k2b-ws", custSlsSum.wsH); window.addEventListener("sls-owner-changed", custSlsSum.localH); custSlsSum.iv = setInterval(() => { custSlsLoadFull(); }, 5 * 60 * 1000); } function custSlsStop() { if (!custSlsSum.iv) return; clearInterval(custSlsSum.iv); custSlsSum.iv = null; window.removeEventListener("k2b-ws", custSlsSum.wsH); window.removeEventListener("sls-owner-changed", custSlsSum.localH); } /* ── ONE search index: custKey → {vins, coBuyers, vehicles, soldBy, phones}, rebuilt when the data or the summary changes ── */ function custSlsBuildIndex(customers, deals, ros, sum) { const idx = new Map(); const ent = (k) => { let e = idx.get(k); if (!e) { e = { vins: new Set(), coBuyers: [], vehicles: new Set(), soldBy: new Set(), phones: new Set(), dealVehicles: [] }; idx.set(k, e); } return e; }; const p10 = new Map(), p7 = new Map(), byName = new Map(); const add = (m, k, v) => { let s = m.get(k); if (!s) { s = new Set(); m.set(k, s); } s.add(v); }; for (const c of Object.values(customers || {})) { if (!c || !c.custNum) continue; const k = String(c.custNum); const e = ent(k); for (const f of CUST_SLS_PHONE_FIELDS) { const d = custSlsDigits(c[f]); if (d.length < 7) continue; e.phones.add(d); const t = custSlsPhone10(c[f]); if (t) add(p10, t, k); add(p7, d.slice(-7), k); } const cd = custSlsDigits(k); if (cd) e.phones.add(cd); if (cd.length === 10) { add(p10, cd, k); add(p7, cd.slice(-7), k); } // a pre-RC30 10-digit customer # was the phone if (/^[2-9]\d{6}$/.test(cd)) add(p7, cd, k); // RC30 (cn): the customer # IS the phone without the area code (Array.isArray(c.aliases) ? c.aliases : []).forEach((a) => { const ad = custSlsDigits(a); if (ad) e.phones.add(ad); if (ad.length === 10) { add(p10, ad, k); add(p7, ad.slice(-7), k); } if (/^[2-9]\d{6}$/.test(ad)) add(p7, ad, k); }); // an old number still finds the account (Array.isArray(c.names) ? c.names : []).forEach((n) => { const nk2 = custSlsNameKey([n && n.last, n && n.first].filter(Boolean).join(" ")); if (nk2) add(byName, nk2, k); }); const nk = custSlsNameKey(c.name || [c.lastName, c.firstName].filter(Boolean).join(" ")); if (nk) add(byName, nk, k); } const one = (s) => (s && s.size === 1 ? Array.from(s)[0] : null); const aliasTo = new Map(); // RC30 (cn): an old number on a deal / RO → the live account for (const c of Object.values(customers || {})) if (c && c.custNum && Array.isArray(c.aliases)) c.aliases.forEach((a) => { if (!aliasTo.has(String(a))) aliasTo.set(String(a), String(c.custNum)); }); const link = (custNum, phone, name) => { if (custNum) { const k0 = String(custNum); return (customers && customers[k0]) ? k0 : (aliasTo.get(k0) || k0); } const t = custSlsPhone10(phone); if (t && one(p10.get(t))) return one(p10.get(t)); const d = custSlsDigits(phone); if (d.length >= 7 && one(p7.get(d.slice(-7)))) return one(p7.get(d.slice(-7))); const nk = custSlsNameKey(name); if (nk && one(byName.get(nk))) return one(byName.get(nk)); return null; }; for (const d of deals || []) { if (!d) continue; const k = link(d.custNum, d.phone, d.buyer); if (!k) continue; const e = ent(k); const vin = String(d.vin || "").toUpperCase().replace(/[\s-]/g, ""); if (vin) e.vins.add(vin); const veh = custSlsVehicleOf(d); if (veh) { e.vehicles.add(veh.toLowerCase()); e.dealVehicles.push(veh); } const cb = custSlsCoBuyerOf(d); if (cb) e.coBuyers.push(cb.toLowerCase().split(/[\s,]+/).filter(Boolean)); if (d.salesperson) e.soldBy.add(String(d.salesperson).toLowerCase()); } for (const r of ros || []) { if (!r) continue; const k = link(r.custNum || (r.cust && r.cust.custNum), r.phone || (r.cust && r.cust.phone), r.customer || (r.cust && r.cust.name)); if (!k) continue; const e = ent(k); const vin = String((r.veh && r.veh.vin) || r.vin || "").toUpperCase().replace(/[\s-]/g, ""); if (vin) e.vins.add(vin); const veh = String(r.vehicle || (r.veh ? [r.veh.year || r.veh.yr, r.veh.make || r.veh.mk, r.veh.model || r.veh.mdl].filter(Boolean).join(" ") : "") || "").trim(); if (veh) e.vehicles.add(veh.toLowerCase()); } for (const k of Object.keys(sum || {})) { const s = sum[k]; if (!s) continue; const e = ent(k); if (s.soldBy) e.soldBy.add(String(s.soldBy).toLowerCase()); (s.vehicles || []).forEach((v) => { if (v.label) e.vehicles.add(String(v.label).toLowerCase()); if (v.soldBy) e.soldBy.add(String(v.soldBy).toLowerCase()); }); } return idx; } // Does this customer match what was typed? (runs over the customer list + the index only) function custSlsMatch(c, e, q) { const ql = String(q || "").toLowerCase().trim(); if (!ql) return true; const toks = ql.split(/[\s,]+/).filter(Boolean); const nameToks = [c.firstName, c.middleName, c.lastName, c.name, c.company].filter(Boolean).join(" ").toLowerCase().split(/[\s,]+/).filter(Boolean); if (toks.length && toks.every((t) => nameToks.some((n) => n.startsWith(t)))) return true; // either order const qd = ql.replace(/\D/g, ""); if (window.DMS && window.DMS.custQueryHit && window.DMS.custQueryHit(q, c.custNum)) return true; // RC31 (fx31, F6): an old number / a phone in any format (one reading for every box) if (qd.length >= 3 && /^[\d\s()+.\-]+$/.test(ql) && e) { for (const p of e.phones) if (p.indexOf(qd) >= 0) return true; } if (String(c.custNum || "").toLowerCase().indexOf(ql) >= 0) return true; if (Array.isArray(c.aliases) && c.aliases.some((a) => String(a).toLowerCase().indexOf(ql) >= 0)) return true; // RC30 (cn): the old number from an old printout if (Array.isArray(c.names) && c.names.some((n) => n && toks.length && toks.every((t) => [n.first, n.last].filter(Boolean).join(" ").toLowerCase().split(/[\s,]+/).some((x) => x.startsWith(t))))) return true; // every person on a household account if (String(c.email || "").toLowerCase().indexOf(ql) >= 0 || String(c.email2 || "").toLowerCase().indexOf(ql) >= 0) return true; if (String(c.city || "").toLowerCase().indexOf(ql) >= 0) return true; if (!e) return false; const qv = ql.replace(/[^a-z0-9]/g, "").toUpperCase(); if (qv.length >= 6) { for (const v of e.vins) if (v.endsWith(qv)) return true; } for (const v of e.vehicles) if (v.indexOf(ql) >= 0) return true; for (const cb of e.coBuyers) if (toks.every((t) => cb.some((x) => x.startsWith(t)))) return true; for (const s of e.soldBy) { if (s.indexOf(ql) >= 0) return true; const st = s.split(/[\s,/]+/); if (toks.every((t) => st.some((x) => x.startsWith(t)))) return true; } return false; } // The Owner chip of a list row. function custSlsOwnerChip(s, me) { if (!s) return { text: "-", kind: "none" }; if (s.claim && s.claim.userId) return s.claim.userId === me ? { text: "Mine", kind: "mine" } : { text: "Claimed by " + (s.claim.userName || s.claim.userId), kind: "claimed" }; if ((s.status === "owned" || s.status === "assigned") && s.ownerId) return s.ownerId === me ? { text: "Mine", kind: "mine" } : { text: s.ownerName || s.ownerId, kind: "owned" }; if (s.status === "orphan" || s.anyOrphan) return { text: "Orphan", kind: "orphan" }; if (s.status === "unreviewed") return { text: "Waiting for a name match", kind: "unreviewed" }; if ((s.ownerIds || []).indexOf(me) >= 0) return { text: "Mine", kind: "mine" }; return { text: "-", kind: "none" }; } const CUST_SLS_CHIP_COLORS = { mine: ["#E6F4EA", "#16803A"], owned: ["#EEF1F6", "#3B4558"], claimed: ["#EEF1F6", "#3B4558"], orphan: ["#FDECEA", "#B3261E"], unreviewed: ["#FFF3CD", "#8A5A00"], none: ["transparent", "#8A93A6"] }; // The width of a box (narrow screens stack the list and the detail). function custSlsUseWidth(ref) { const [w, setW] = React.useState(1200); React.useLayoutEffect(() => { const el = ref.current; if (!el) return; const upd = () => setW(el.clientWidth || 1200); upd(); if (typeof ResizeObserver === "undefined") { window.addEventListener("resize", upd); return () => window.removeEventListener("resize", upd); } const ro = new ResizeObserver(upd); ro.observe(el); return () => ro.disconnect(); }, []); return w; } const Customers = ({ onClose, go }) => { // Customers live in the shared ledger (DMS.customers) so they appear // everywhere and persist with the rest of the dealership data. const [tick, force] = React.useReducer(n => n + 1, 0); React.useEffect(() => DMS.subscribe(force), []); const [sumV, setSumV] = React.useState(0); const customers = DMS.customers(); const [query, setQuery] = React.useState(""); const [selected, setSelected] = React.useState(null); const [filter, setFilter] = React.useState("all"); const [shown, setShown] = React.useState(400); // rows drawn at once (a full old-DMS import can be thousands) React.useEffect(() => { setShown(400); }, [query, filter]); const boxRef = React.useRef(null); const width = custSlsUseWidth(boxRef); const narrow = width < 760; // the list's own width (the detail panel takes about a third): below ~960 px the list drops // City / ST (shown under the name) and Last updated (shown in the detail) so Owner stays in view const listW = Math.max(0, width - 54 - Math.max(310, (width - 54) / 3.3)); const compact = !narrow && listW < 960; // Credit-app completeness (CreditApp.jsx.txt exports it; loads after this file). const creditPct = (c) => (window.jbCreditCompleteness ? window.jbCreditCompleteness(c).pct : null); const isAdmin = !!(window.DMS && typeof DMS.isAdmin === "function" && DMS.isAdmin()); const restricted = (window.DMS && typeof DMS.isRestricted === "function") ? !!DMS.isRestricted() : (window.jbIsRestrictedRole ? window.jbIsRestrictedRole() : true); // Sales (and role-less sessions) never see SSN, birth date, licence, credit or income here. const salesView = restricted || !!(window.DMS && DMS.roleIs && DMS.roleIs("Sales")); const isMgr = !!(window.DMS && ((DMS.isAdmin && DMS.isAdmin()) || (DMS.roleIs && DMS.roleIs("Sales Manager")))); const myId = custSlsMyId(); // ownership data (only when the server has the sales features) React.useEffect(() => { const fn = (v) => setSumV(v); custSlsSum.listeners.add(fn); custSlsStart(); custSlsGetMe().then((m) => { if (m && (m.roleClass === "sales" || m.roleClass === "manager")) custSlsLoadFull(); }); return () => { custSlsSum.listeners.delete(fn); if (!custSlsSum.listeners.size) custSlsStop(); }; }, []); custSlsCheckTok(); const me = custSlsSum.me; // undefined while checking, null = features off // ownership columns / filters / box: Sales and managers only (other roles get 403 on the owner routes) const own = !!(me && (me.roleClass === "sales" || me.roleClass === "manager")); const sum = custSlsSum.customers; // Hand-off from the CRM / the bell ("Open customer"): jb_cust_focus = custNum, read-once. React.useEffect(() => { try { const cn = localStorage.getItem("jb_cust_focus"); if (cn) { localStorage.removeItem("jb_cust_focus"); setSelected(cn); } } catch (e) {} const h = (e) => { const cn = e && e.detail && e.detail.custNum; if (cn) { try { localStorage.removeItem("jb_cust_focus"); } catch (x) {} setSelected(String(cn)); setQuery(""); setFilter("all"); } }; window.addEventListener("sls-cust-focus", h); return () => window.removeEventListener("sls-cust-focus", h); }, []); const deals = (window.DMS && window.DMS.deals && window.DMS.deals()) || []; const ros = (window.DMS && window.DMS.repairOrders && window.DMS.repairOrders()) || []; // ONE index, rebuilt when the data (tick) or the summary (sumV) changes — never per keystroke. const index = React.useMemo(() => custSlsBuildIndex(customers, deals, ros, sum), [tick, sumV]); const list = Object.values(customers).sort((a, b) => (b.saved || "").localeCompare(a.saved || "") ); const filtered = list.filter((c) => { const s = sum[c.custNum]; if (own && filter === "mine" && !(s && ((s.ownerIds || []).indexOf(myId) >= 0 || (s.claim && s.claim.userId === myId)))) return false; if (own && filter === "orphans" && !(s && s.anyOrphan)) return false; if (own && filter === "inservice" && !(s && s.inService)) return false; return custSlsMatch(c, index.get(String(c.custNum)), query); }); const deleteCust = (custNum) => { if (!(DMS.isOwner && DMS.isOwner())) { window.k2bToast && window.k2bToast("Only the dealership owner can delete a customer — a customer number is kept for good. Ask the owner to renumber it instead.", "warn", 6000); return; } // RC30 (cn) if (!confirm(`Delete customer ${custNum}? Their deals will not be deleted, and the number ${custNum} is never given out again.`)) return; const r = DMS.deleteCustomer(custNum); if (r && r.ok === false) { window.k2bToast && window.k2bToast(r.msg, "warn", 6000); return; } if (selected === custNum) setSelected(null); window.k2bToast && window.k2bToast(`Customer ${custNum} deleted.`, "info"); }; const counts = own ? { mine: list.filter((c) => { const s = sum[c.custNum]; return s && ((s.ownerIds || []).indexOf(myId) >= 0 || (s.claim && s.claim.userId === myId)); }).length, orphans: list.filter((c) => { const s = sum[c.custNum]; return s && s.anyOrphan; }).length, inservice: list.filter((c) => { const s = sum[c.custNum]; return s && s.inService; }).length, } : {}; const chip = (id, key, label) => ( ); const showCredit = !salesView && !compact; const cols = narrow ? 2 : (5 + (own ? 3 : 0) + (showCredit ? 1 : 0) - (compact ? 2 : 0)); const sel = selected && customers[selected] ? customers[selected] : null; const detail = sel ? ( setSelected(null) : null} onDelete={isAdmin ? () => deleteCust(selected) : null} /> ) : null; return (
{/* Header */}
Customers
Customer directory. New customers are added in the CRM (+ New Lead). {list.length > 0 ? ` ${list.length} customer${list.length === 1 ? "" : "s"} on file.` : ""}
setQuery(e.target.value)} placeholder="Search name, Customer #, phone, email, VIN, vehicle, sold by…" style={{ minWidth: 0, flex: narrow ? "1 1 100%" : "0 1 320px", width: narrow ? "100%" : 320, boxSizing: "border-box" }} /> {!restricted && go && go("uploadtool")}>Import} go && go("crm")}> 🎯 Open CRM ▸
{own && list.length > 0 && (
{chip("cust-f-all", "all", "All")} {chip("cust-f-mine", "mine", "My customers")} {chip("cust-f-orphans", "orphans", "Orphans")} {chip("cust-f-inservice", "inservice", "In service now")}
)} {!restricted && { setSelected(String(cn)); setQuery(""); setFilter("all"); }} />} {/* RC30 (cn): the office's "customers to settle" list */} {list.length === 0 ? ( go && go("crm")} onImport={restricted ? null : () => go && go("uploadtool")} /> ) : narrow && detail ? (
{detail}
) : (
{/* List */}
{narrow ? ( ) : ( {!compact && } {own && } {own && } {own && } {showCredit && } {!compact && } )} {filtered.length === 0 && ( )} {filtered.slice(0, Math.max(shown, selected ? filtered.findIndex((x) => x.custNum === selected) + 1 : 0)).map(c => { const s = sum[c.custNum] || null; const ch = custSlsOwnerChip(s, myId); const e = index.get(String(c.custNum)); const vehs = s && s.vehicles && s.vehicles.length ? s.vehicles.map((v) => v.label).filter(Boolean) : (e ? Array.from(new Set(e.dealVehicles)) : []); const col = CUST_SLS_CHIP_COLORS[ch.kind] || CUST_SLS_CHIP_COLORS.none; const chipEl = own ? {ch.text} : null; const name = (window.CustNum && Array.isArray(c.names) && c.names.length > 1 ? window.CustNum.displayName(c) : [c.lastName, c.firstName].filter(Boolean).join(", ")) || c.name || "—"; // RC30 (cn): a household shows every name return ( setSelected(c.custNum)} style={{ background: selected === c.custNum ? "var(--jb-blue-tint)" : "transparent", cursor: "pointer", borderBottom: "1px solid var(--jb-border)", }}> {narrow ? ( <> ) : ( <> {!compact && } {own && } {own && } {own && } {showCredit && } {!compact && } )} ); })}
Customer{own ? "Owner" : ""}
Customer # NameCity / STVehiclesSold bySold onOwnerCredit AppLast updated
{query ? No customers match '{query}' · : (filter === "mine" ? "No customers of yours yet." : filter === "orphans" ? "No orphan customers." : filter === "inservice" ? "None of these customers is in service right now." : "No customers.")}
{name}
#{c.custNum}{c.phone ? " · " + custSlsFmtPhone(c.phone) : ""}{c.city ? " · " + c.city : ""}
{vehs.length ?
{vehs.join(", ")}
: null} {own && s && (s.soldBy || s.soldOn) ?
Sold by {s.soldBy || "no salesperson"}{s.soldOn ? " · " + custSlsFmtDate(s.soldOn) : ""}
: null}
{chipEl}
{c.custNum}
{c.phone || ""}
{name}
{compact && (c.city || c.state) ?
{[c.city, c.state].filter(Boolean).join(", ")}
: null} {compact && !salesView ? (() => { const pct = creditPct(c); return pct != null && pct >= 80 ?
✓ credit app
: null; })() : null} {!salesView && c.dlNumber &&
DL {window.jbMaskPII ? window.jbMaskPII(c.dlNumber, "dl") : c.dlNumber}{c.state ? " · " + c.state : ""}
}
{[c.city, c.state].filter(Boolean).join(", ") || "—"}{vehs.length ? vehs.join(", ") : "—"}{s && s.soldBy ? s.soldBy : (s && s.soldOn ? "no salesperson" : "—")}{s && s.soldOn ? custSlsFmtDate(s.soldOn) : "—"}{chipEl} {(() => { const pct = creditPct(c); const ok = pct != null && pct >= 80; return ( {ok ? "✓ credit app" : "○ no credit app"}{pct != null && pct > 0 && !ok ? ` (${pct}%)` : ""} ); })()} {c.saved ? new Date(c.saved).toLocaleDateString() : "—"}
{filtered.length > shown ? (
) : null}
{/* Detail panel */} {narrow ? null : detail ? detail : (
Select a customer to see details and recent deals.
)}
)}
); }; /* ─── Empty state ──────────────────────────────────────────── */ const EmptyCustomersState = ({ onCrm, onImport }) => (
👤
No customers yet
Customer directory. New customers are added in the CRM (+ New Lead) — search the file, add them or scan a license right there, then Desk It.
🎯 Open CRM ▸ {onImport && Import from old DMS}
How customers work in JBREWS

Customer # = the phone without the area code (7 digits).
Example: (573) 555-0123 → 5550123. Two people on one phone share ONE account (both names on it). A customer with no phone gets a number from K2B (starts with 1). The number never changes; only the owner can renumber, and the old number still finds the account.

Each customer holds: name, address, DL/DOB (if scanned), phone, and a list of deals. The one path: CRM → + New Lead → Desk It → Cash / Finance Deal → Post → Funded.

Importing? The template's customer_number column IS the 7-digit number; a row with a phone and no number is numbered from the phone, a row with neither gets a K2B number.

); /* ─── RC30 (cn): "Customers to settle" — two customers whose phones end in the same 7 digits on different area codes (the renumbering parked one on a K2B number); the office decides: one household (fold into the keeper) or two different people (both numbers stay). Office roles only. ─── */ const CustSettlePanel = ({ onPick }) => { const [items, setItems] = React.useState(null); const [may, setMay] = React.useState(false); const [busy, setBusy] = React.useState(""); const load = React.useCallback(async () => { const r = await (window.DMS && window.DMS.custSettleList ? window.DMS.custSettleList("open") : Promise.resolve({ items: [] })); setItems((r && r.items) || []); setMay(!!(r && r.may && r.may.decide)); }, []); React.useEffect(() => { load(); }, [load]); if (!items || !items.length) return null; const decide = async (id, decision) => { setBusy(id); const r = await window.DMS.custSettle(id, decision); setBusy(""); window.k2bToast && window.k2bToast(r.msg, r.ok ? "ok" : "warn", 6000); load(); }; // RC33 (svi): a name the service lists printed for an account on file (kind name_differs) — household / keep / the file's spelling / a different person const decideName = async (id, decision, name) => { setBusy(id); const D = window.DMS; let msg = "", ok = false; try { const r = await D._command("/customers/settle/" + encodeURIComponent(id), Object.assign({ decision }, name ? { name } : {}), "cn-name-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), "/customers/op/"); ok = !!r.ok; msg = ok ? ((r.res && r.res.message) || "Done.") : ((r.err && r.err.body && r.err.body.error) || "Could not settle."); } catch (e) { msg = (e && e.message) || "Could not settle."; } setBusy(""); window.k2bToast && window.k2bToast(msg, ok ? "ok" : "warn", 7000); load(); }; return (
Customers to settle ({items.length}) — same 7 digits, different area codes{items.some((x) => x.kind === "name_differs") ? " · another name on an account (from the service lists)" : ""}
{items.map((it) => it.kind === "name_differs" ? (
{(it.detail && it.detail.words) || it.id} {it.detail && it.detail.custNum && onPick && onPick(it.detail.custNum)}>#{it.detail.custNum}} {may ? <> : the office decides}
) : (
{(it.detail && it.detail.words) || it.id} {it.detail && it.detail.keeper && onPick && onPick(it.detail.keeper.custNum)}>#{it.detail.keeper.custNum}} {it.detail && it.detail.parked && onPick && onPick(it.detail.parked.custNum)}>#{it.detail.parked.custNum}} {may ? <> : the office decides}
))}
); }; /* ─── Owner box (who this customer belongs to) ─────────────── */ const CustSlsOwnerBox = ({ c, s, me, myId, isMgr }) => { const [showRe, setShowRe] = React.useState(false); const [toUser, setToUser] = React.useState(""); const [reason, setReason] = React.useState(""); const [busy, setBusy] = React.useState(false); const [msg, setMsg] = React.useState(""); const status = s ? s.status : "none"; const vehicles = (s && s.vehicles) || []; const claim = s && s.claim; const holder = !!(claim && claim.userId === myId); const mine = holder || !!(s && (s.ownerIds || []).indexOf(myId) >= 0); const canMgr = !!(me && me.canManage && isMgr); const myVeh = vehicles.filter((v) => v.ownerId === myId && (v.status === "owned" || v.status === "assigned")).map((v) => v.label).filter(Boolean); let line; if (claim) line = holder ? "Claimed by you" : "Claimed by " + (claim.userName || claim.userId); else if (status === "assigned") line = s.ownerId === myId ? "Your customer (a manager gave them to you)" : (s.ownerName || s.ownerId) + "'s customer (set by a manager)"; else if (status === "owned") line = s.ownerId === myId ? "Your customer" + (myVeh.length ? " (you sold the " + myVeh.join(", the ") + ")" : "") : (s.ownerName || s.ownerId) + "'s customer"; else if (status === "orphan") line = s.soldBy ? "Orphan - sold by " + s.soldBy + ", who no longer works here" : "Orphan - the deal had no salesperson"; else if (status === "unreviewed") line = "Sold by " + (s.soldBy || "an old salesperson") + " - waiting for a manager to match that name"; else line = "No vehicle bought here on file - no salesperson"; // per-vehicle lines when the vehicles have different owners const diff = vehicles.length > 1 && new Set(vehicles.map((v) => v.status + "|" + (v.ownerId || ""))).size > 1; const vLine = (v) => { const who = v.status === "orphan" ? "orphan" + (v.soldBy ? " (sold by " + v.soldBy + ")" : "") : v.status === "unreviewed" ? "waiting for a name match (" + (v.soldBy || "old name") + ")" : v.ownerId === myId ? "yours" : (v.status === "owned" || v.status === "assigned" || v.status === "claimed") ? ((v.soldBy && v.status === "owned" ? v.soldBy : v.ownerId) + "'s") : "-"; return (v.label || "Vehicle") + (v.vinLast8 ? " · VIN …" + v.vinLast8 : "") + " - " + who; }; const days = claim && claim.daysLeft != null ? claim.daysLeft : null; const assignable = ((window.DMS && DMS.roster && DMS.roster()) || []).filter((u) => u && u.id && ["sales", "manager"].indexOf(typeof window.slsRoleClass === "function" ? window.slsRoleClass(u.role) : (/^sales$/i.test(u.role || "") ? "sales" : "manager")) >= 0) .sort((a, b) => String(a.name || a.id).localeCompare(String(b.name || b.id))); const changed = () => { try { window.dispatchEvent(new CustomEvent("sls-owner-changed", { detail: { custKeys: [c.custNum] } })); } catch (e) {} }; const release = async () => { if (!confirm("Give " + (c.name || "this customer") + " back to the orphan pool?")) return; setBusy(true); setMsg(""); try { await custSlsApi("/customer-owners/release", { method: "POST", body: { custKey: c.custNum } }); custSlsToast((c.name || "The customer") + " is back in the orphan pool.", "ok"); changed(); } catch (e) { setMsg(custSlsErr(e, "Could not release this customer.")); } setBusy(false); }; const reassign = async () => { if (!toUser) { setMsg("Pick who gets this customer."); return; } if (!reason.trim()) { setMsg("Give a reason (the salesperson sees it)."); return; } setBusy(true); setMsg(""); try { const r = await custSlsApi("/customer-owners/reassign", { method: "POST", body: { custKeys: [c.custNum], toUser, reason: reason.trim().slice(0, 300), moveLeads: true } }); const bad = ((r && r.results) || []).find((x) => !x.ok); if (bad) setMsg(bad.error || "Could not give this customer to that person."); else { const u = assignable.find((x) => x.id === toUser); custSlsToast((c.name || "Customer") + " now belongs to " + (u ? u.name : toUser) + ".", "ok"); setShowRe(false); setReason(""); setToUser(""); changed(); } } catch (e) { setMsg(custSlsErr(e, "Could not give this customer to that person.")); } setBusy(false); }; const tone = mine ? ["#E6F4EA", "#16803A"] : status === "orphan" ? ["#FDECEA", "#B3261E"] : status === "unreviewed" ? ["#FFF3CD", "#8A5A00"] : ["var(--jb-surface-2)", "var(--jb-navy)"]; return (
Salesperson
{line} {claim && days != null ? <> - {days === 1 ? "1 day left" : days + " days left"} : null}
{claim && holder ?
Log a call, text, email or visit to keep them for another {(me && me.claimDays) || 30} days.
: null} {diff ? (
{vehicles.map((v, i) =>
• {vLine(v)}
)}
) : null} {(mine && window.slsLogContact) || holder || canMgr ? (
{mine && window.slsLogContact ? : null} {holder ? : null} {canMgr ? : null}
) : null} {canMgr && showRe ? (
setReason(e.target.value)} placeholder="Reason" style={{ flex: "1 1 160px", minWidth: 0, padding: "4px 6px", fontSize: 12.5 }} />
) : null} {msg ?
{msg}
: null}
); }; /* ─── Contact details form (every role that can see this screen) ─── */ const CUST_SLS_PREFS = [["", "No preference"], ["Call", "Phone call"], ["Text", "Text"], ["Email", "Email"]]; const CustSlsContactForm = ({ c, onDone }) => { const [f, setF] = React.useState(() => ({ phone: c.phone || "", cell: c.cell || c.mobile || "", email: c.email || "", street: c.street || c.address || "", city: c.city || "", state: c.state || "", zip: c.postal || c.zip || "", pref: c.preferredContact || c.contactPref || "", })); const [msg, setMsg] = React.useState(""); const set = (k) => (e) => setF(Object.assign({}, f, { [k]: e.target.value })); const save = () => { const email = f.email.trim(); if (email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { setMsg("That email address does not look right."); return; } const st = f.state.trim().toUpperCase(); if (st && !/^[A-Z]{2}$/.test(st)) { setMsg("Use the 2-letter state, like MO."); return; } const zip = f.zip.trim(); if (zip && !/^\d{5}(-\d{4})?$/.test(zip)) { setMsg("The ZIP code should be 5 digits."); return; } for (const k of ["phone", "cell"]) { const d = custSlsDigits(f[k]); if (f[k].trim() && d.length !== 10 && !(d.length === 11 && d[0] === "1")) { setMsg("A phone number needs 10 digits, like (573) 555-0123."); return; } } // Only the contact fields go in; the customer number never changes (RC30: a changed phone changes the contact number, not the Customer # — the owner renumbers). const rec = { custNum: c.custNum, phone: f.phone.trim() ? custSlsFmtPhone(f.phone) : "", cell: f.cell.trim() ? custSlsFmtPhone(f.cell) : "", email, street: f.street.trim(), city: f.city.trim().toUpperCase(), state: st, postal: zip, preferredContact: f.pref }; if (c.zip != null) rec.zip = zip; if (c.mobile != null && c.cell == null) rec.mobile = rec.cell; const r = DMS.upsertCustomer(rec); if (r && r.ok === false) { setMsg(r.msg || "Could not save."); return; } custSlsToast("Contact details saved for " + (c.name || [c.lastName, c.firstName].filter(Boolean).join(", ") || c.custNum) + ".", "ok"); onDone && onDone(); }; const lab = (t, el) => ( ); const inp = { padding: "5px 7px", fontSize: 13, minWidth: 0, width: "100%", boxSizing: "border-box" }; return (
{lab("Phone", )} {lab("Cell", )} {lab("Email", )} {lab("Street", )} {lab("City", )} {lab("State", )} {lab("ZIP", )} {lab("Preferred contact", )}
{msg ?
{msg}
: null}
); }; /* ─── Customer detail panel ────────────────────────────────── */ const CustomerDetail = ({ c, go, onDelete, restricted, salesView, narrow, own, me, myId, isMgr, sumEntry, onBack }) => { // ONE PATH: no deal is created here. "Start Lead in CRM" hands the customer // to the CRM's New Lead; Sales can see deals but not open the pipeline. const canOpenDeals = !restricted; const [editing, setEditing] = React.useState(false); React.useEffect(() => { setEditing(false); }, [c.custNum]); // a salesperson's look-up leaves a server trail (at most once an hour per customer) React.useEffect(() => { if (!own || !salesView || !c.custNum) return; custSlsApi("/customer-owners/viewed", { method: "POST", body: { custKey: String(c.custNum) } }).catch(() => {}); }, [c.custNum, own, salesView]); const openDeal = (id) => { if (!canOpenDeals || !id) return; try { localStorage.setItem("jb_deal_focus", id); } catch (e) {} go && go("deal"); }; const stageOf = (d) => (window.DMS && window.DMS.dealStage) ? window.DMS.dealStage(d) : (d.postedToGL ? "Posted" : (d.status || "Working")); const mask = (v) => (window.jbMaskPII ? window.jbMaskPII(v, "dl") : v); // DOB is shown year-only in the directory (full date lives on the credit app). const dobYear = (v) => { const m = String(v || "").match(/(\d{4})/); return m ? "••/••/" + m[1] : (v ? "••••" : "—"); }; const credit = c.credit || {}; const emp = credit.employment || {}; const cc = (!salesView && window.jbCreditCompleteness) ? window.jbCreditCompleteness(c) : null; const money = (v) => { const n = parseFloat(String(v == null ? "" : v).replace(/[^0-9.\-]/g, "")); return isFinite(n) && n ? "$" + n.toLocaleString("en-US", { maximumFractionDigits: 0 }) : ""; }; const openCreditApp = () => { try { localStorage.setItem("jb_credit_cust", c.custNum); } catch (e) {} go && go("creditapp"); }; // Pull this customer's deals from the shared ledger. const fullName = [c.lastName, c.firstName].filter(Boolean).join(", "); // normalized name match (handles "LAST, FIRST" vs "FIRST LAST") const norm = (s) => String(s || "").toUpperCase().replace(/[.,]/g, "").split(/\s+/).filter(Boolean).sort().join(" "); const myName = norm(`${c.firstName || ""} ${c.lastName || ""}`); const myPhones = CUST_SLS_PHONE_FIELDS.map((f) => custSlsPhone10(c[f])).concat([custSlsPhone10(c.custNum)]).filter(Boolean); const myKeys = [String(c.custNum)].concat(Array.isArray(c.aliases) ? c.aliases.map(String) : []); // RC30 (cn): the number + every old number const isMine = (k) => k !== undefined && k !== null && k !== "" && myKeys.indexOf(String(k)) >= 0; const deals = React.useMemo(() => { const all = (window.DMS && window.DMS.deals()) || []; return all.filter(d => isMine(d.custNum) || d.buyer === fullName || norm(d.buyer) === myName || (!d.custNum && d.phone && myPhones.indexOf(custSlsPhone10(d.phone)) >= 0)); }, [c.custNum, c.lastName, c.firstName, c.phone, c.aliases]); // CONNECTED SYSTEM: this customer's service + parts + A/R, live from the ledger. const ros = React.useMemo(() => { const all = (window.DMS && window.DMS.repairOrders && window.DMS.repairOrders()) || []; return all.filter(r => isMine(r.custNum) || (r.cust && isMine(r.cust.custNum)) || norm(r.customer) === myName); }, [c.custNum, c.lastName, c.firstName, c.aliases]); const appts = React.useMemo(() => { const all = (window.DMS && window.DMS.appointments && window.DMS.appointments()) || []; return all.filter(a => isMine(a.custNum) || norm(a.customer) === myName); }, [c.custNum, c.lastName, c.firstName, c.aliases]); const arAcct = React.useMemo(() => { if (salesView) return null; const all = (window.DMS && window.DMS.arCustomers && window.DMS.arCustomers()) || []; return all.find(a => isMine(a.custNum) || String(a.acct) === String(c.custNum) || norm(a.name) === myName) || null; // RC30 (cn): a customer's A/R account may carry the customer # as its acct }, [c.custNum, c.lastName, c.firstName, salesView, c.aliases]); // Vehicles this customer owns/bought — union of delivered deals + serviced VINs. const vehicles = React.useMemo(() => { const m = new Map(); deals.forEach(d => { const v = custSlsVehicleOf(d); if (v) m.set((d.vin || v), { desc: v, vin: d.vin || "", src: "Purchased" }); }); ros.forEach(r => { if (r.vehicle && !m.has(r.vin || r.vehicle)) m.set((r.vin || r.vehicle), { desc: r.vehicle, vin: r.vin || "", src: "Serviced" }); }); return [...m.values()]; }, [deals, ros]); const arBal = arAcct ? (window.DMS.arBalance ? window.DMS.arBalance(arAcct.acct) : (arAcct.balance ?? 0)) : 0; const ar$ = (n) => "$" + (Math.abs(+n || 0)).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const tiles = [["Deals", deals.length, "var(--jb-blue)"], ["Vehicles", vehicles.length, "var(--jb-navy)"], ["Service ROs", ros.length, "var(--jb-green)"], ["Appts", appts.length, "#B26A00"]]; if (!salesView) tiles.push(["A/R", arAcct ? ar$(arBal) : "—", Math.abs(arBal) > 0.004 ? "var(--jb-red)" : "var(--jb-text-dim)"]); return (
{onBack ? : null}
{[(c.firstName || "")[0], (c.lastName || "")[0]].filter(Boolean).join("") || "?"}
{[c.firstName, c.middleName, c.lastName].filter(Boolean).join(" ") || c.name || "(unnamed)"}
Customer # {c.custNum}{Array.isArray(c.aliases) && c.aliases.length ? · was {c.aliases.join(", ")} : null}{c.numberSource && window.CustNum && window.CustNum.SOURCE_WORDS[c.numberSource] && c.numberSource !== "phone" && c.numberSource !== "migrated" ? · {window.CustNum.SOURCE_WORDS[c.numberSource]} : null}
{Array.isArray(c.names) && c.names.length > 1 ?
On this account: {c.names.map((n) => [n.last, n.first].filter(Boolean).join(", ")).join(" · ")}
: null} {c.parkedFrom ?
Parked: shares the 7 digits {c.parkedFrom} with another customer on another area code — the office settles it below.
: null} {c.saved ?
Last updated {new Date(c.saved).toLocaleDateString()}
: null}
{ try { localStorage.setItem("jb_crm_cust", c.custNum); } catch (e) {} go && go("crm"); }}> 🎯 Start Lead in CRM ▸ {/* RC30 (cn): native buttons (K2BButton carries no id) so the screen checks and the help can name them */} {!salesView && } {/* RC30 (cn) fix round (attacker L2): the office takes a wrongly added person off the account, or gives a person a number of their own */} {!salesView && Array.isArray(c.names) && c.names.length > 1 && DMS.removeHouseholdName && } {!salesView && Array.isArray(c.names) && c.names.length > 1 && DMS.splitCustomer && } {DMS.isOwner && DMS.isOwner() && } {onDelete && DMS.isOwner && DMS.isOwner() && }
{own ? : null}
{c.cell || c.mobile ? : null} {c.email ? : null} {!salesView && } {!salesView && } {!salesView && } {c.preferredContact ? : null}
{editing ? setEditing(false)} /> : }
{salesView ? (
🔒 Private details (SSN, birth date, licence, credit) are hidden for your role.
) : (() => { // Credit Application — the customer record IS the credit app (CreditApp.jsx.txt) const pct = cc ? cc.pct : null; const ok = pct != null && pct >= 80; const col = ok ? "var(--jb-green)" : pct ? "#B26A00" : "var(--jb-text-dim)"; return (
Credit Application
{pct == null ? "—" : pct + "%"} Credit App ▸
SSN
{c.ssn ? (window.JbSecret ? : (window.jbMaskPII ? window.jbMaskPII(c.ssn, "ssn") : "•••-••-" + String(c.ssn).slice(-4))) : "—"}
{cc && cc.missing.length > 0 && (
Missing: {cc.missing.join(" · ")}
)}
); })()} {/* CONNECTED SYSTEM: one customer, every department — a glance strip */}
{tiles.map(([lab, val, col]) => (
{val}
{lab}
))}
Deals ({deals.length})
{deals.length === 0 ? (
No deals yet for this customer.
) : (
{deals.map(d => { const limited = !!d._limited; const stg = limited ? "" : stageOf(d); const done = stg === "Funded"; const so = custSlsSoldOn(d); const clickable = canOpenDeals && !limited; return (
{ if (clickable) openDeal(d.id); }} title={clickable ? "Open in Deal Pipeline" : ""} style={{ display: "flex", alignItems: "center", gap: "4px 10px", flexWrap: "wrap", padding: "6px 10px", background: "var(--jb-surface-2)", borderRadius: 4, fontSize: 12, cursor: clickable ? "pointer" : "default", }}> {d.id} {custSlsVehicleOf(d) || "no vehicle"} {so ? "sold " + custSlsFmtDate(so) : "not delivered"} {d.salesperson ? "by " + d.salesperson : "no salesperson"} {stg ? ( {stg}) : null}
); })}
)} {/* Vehicles owned/serviced */} {vehicles.length > 0 && ( <>
Vehicles ({vehicles.length})
{vehicles.map((v, i) => (
{v.desc} {v.vin && {v.vin}} {v.src} {v.vin && window.ImpVinHistoryButton ? : null}{/* RC26 (inv) */}
))}
)} {/* RC26 (inv): the old system's repair orders on this customer (linked by phone / VIN, or carrying this customer # = the phone) */} {c.custNum && window.ImpCustomerOldRos ? : null} {/* Service history — the sales↔service bridge */}
Service History ({ros.length}){appts.length ? ` · ${appts.length} upcoming` : ""}
{ros.length === 0 && appts.length === 0 ? (
No service visits on record. go && go("servicedept")}>Open Service ▸
) : (
{appts.map(a => (
APPT {a.date ? (window.slsFmtDay ? window.slsFmtDay(a.date) : a.date) + " " : ""}{a.time || ""} · {a.serviceType || a.concern || a.vehicle || ""} {a.advisor || ""}
))} {ros.map(r => (
{r.id} {r.vehicle || ""} {r.status || "OPEN"}
))}
)} {/* A/R standing */} {!salesView && arAcct && Math.abs(arBal) > 0.004 && (
A/R Balance {ar$(arBal)} acct {arAcct.acct} go && go("acctoffice")}>Open A/R ▸
)}
Deals start in the CRM — Start Lead in CRM ▸ above, then Desk It. {restricted ? " (A manager or F&I desks the lead.)" : ""}
); }; const DetailRow = ({ label, value, multiline }) => (
{label}
{value || "—"}
); const custTh = { padding: "10px 8px", textAlign: "left", fontSize: 10, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--jb-text-dim)", fontWeight: 700, borderBottom: "1px solid var(--jb-border)" }; const custTd = { padding: "9px 8px", fontSize: 13, color: "var(--jb-text)", verticalAlign: "top" }; Object.assign(window, { Customers, custSlsBuildIndex, custSlsMatch });