/* © 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. */
/* eslint-disable */
// JBREWS DMS — CRM (Customer Resource Management). THE front door of the
// store: every customer and every deal starts here (ONE PATH).
// + New Lead → find the customer (or add them right here / Scan DL) → lead
// → DESK IT (managers / F&I / admin) creates THE deal at stage "Desked" and
// hands it to Desking → Cash Deal | Finance Deal → Post → Funded.
// Also: activity logging (call/text/email/note), follow-up cadence that
// auto-schedules reminders, per-salesperson due/overdue reminders. Cross-tab
// real-time like every other module.
const CRM_KEY = "jbrews-crm-v1";
// phone helpers. RC30 (cn): the customer # is the phone WITHOUT the area code (7 digits) — CustNum.jsx.txt (== the server's rule); the 10-digit phone stays the contact number
const crmPhoneDigits = (p) => { let d = String(p || "").replace(/\D/g, ""); if (d.length === 11 && d[0] === "1") d = d.slice(1); return d.slice(-10); };
const crmCustNumOf = (p) => (window.CustNum ? (window.CustNum.custNumFromPhone(p).num7 || "") : crmPhoneDigits(p).slice(-7));
const crmFmtPhone = (p) => { const d = crmPhoneDigits(p); return d.length === 10 ? `(${d.slice(0, 3)}) ${d.slice(3, 6)}-${d.slice(6)}` : String(p || ""); };
const crmCanDesk = () => {
if (window.DMS && typeof window.DMS.isRestricted === "function") return !window.DMS.isRestricted();
return !(window.jbIsRestrictedRole ? window.jbIsRestrictedRole() : (window.jbRoleIs ? window.jbRoleIs("Sales") : true));
};
// Leads live in the shared DMS core (server-synced on the deployed build —
// every station sees new leads in real time). localStorage is only the
// fallback when the core isn't loaded, plus a one-time migration source.
const crmLoad = () => {
if (window.DMS && window.DMS.crmLeads) {
let leads = window.DMS.crmLeads();
if (!leads.length) {
// one-time migration of any leads saved by the old local-only CRM
try {
const old = JSON.parse(localStorage.getItem(CRM_KEY) || "[]");
if (old.length) { window.DMS.crmSetLeads(old); localStorage.removeItem(CRM_KEY); leads = window.DMS.crmLeads(); }
} catch (e) {}
}
return leads;
}
try { return JSON.parse(localStorage.getItem(CRM_KEY) || "[]"); } catch (e) { return []; }
};
const crmSave = (leads) => {
if (window.DMS && window.DMS.crmSetLeads) { window.DMS.crmSetLeads(leads); return; }
try { localStorage.setItem(CRM_KEY, JSON.stringify(leads)); } catch (e) {}
window.dispatchEvent(new CustomEvent("crm-change"));
};
const CRM_SOURCES = ["Walk-In", "Phone", "Internet", "Facebook", "AutoTrader", "Cars.com", "Referral", "Service Drive", "Repeat"];
const CRM_STATUSES = ["New", "Working", "Appt Set", "Demo'd", "Negotiation", "Sold", "Lost"];
const CRM_TONE = { "New": "#1B66A8", "Working": "#B26A00", "Appt Set": "#8050B0", "Demo'd": "#0E7A37", "Negotiation": "#C8862A", "Sold": "#16A34A", "Lost": "#8a8f98" };
// Follow-up cadence — auto-scheduled reminders on every new lead.
const CRM_CADENCE = [
{ day: 0, kind: "call", label: "Same-day call — thank them & confirm what they're looking for" },
{ day: 1, kind: "text", label: "Day-1 text — photo/video of the unit they liked" },
{ day: 3, kind: "email", label: "Day-3 email — payment options & similar units" },
{ day: 7, kind: "call", label: "Day-7 call — check in, offer a demo drive appointment" },
{ day: 14, kind: "text", label: "Day-14 text — price/incentive update" },
{ day: 30, kind: "call", label: "Day-30 call — still in the market? fresh options" },
];
const crmDays = (iso) => Math.floor((Date.now() - new Date(iso)) / 86400000);
const crmDue = (t) => { const d = new Date(t.due); const today = new Date(); d.setHours(0,0,0,0); today.setHours(0,0,0,0); return (d - today) / 86400000; };
const CRM_ACT_ICON = { call: "📞", text: "💬", email: "✉", note: "📝", visit: "🚶" };
// ── Trade-in on the lead — STRUCTURED fields (tradeYr / tradeMk / tradeMdl /
// tradeVin / tradeMiles / tradePayoff / tradeLender, plus tradeAllow / tradeAcv /
// tradeApprId when it came from a saved Trade Appraisal). `tradeIn` and
// `tradeVehicle` are still written as the joined "YEAR MAKE MODEL" string so
// leads already on the server (and anything else that reads them) keep
// working; an old lead that only has the free-text string is split best-effort.
const CRM_TRADE_KEYS = ["tradeYr", "tradeMk", "tradeMdl", "tradeVin", "tradeMiles", "tradePayoff", "tradeLender", "tradeAllow", "tradeAcv", "tradeApprId"];
const crmTradeSplit = (s) => {
const toks = String(s || "").trim().split(/\s+/).filter(Boolean);
const yi = toks.findIndex(t => /^(19|20)\d{2}$/.test(t));
if (yi < 0) return { tradeYr: "", tradeMk: toks[0] || "", tradeMdl: toks.slice(1).join(" ") };
return { tradeYr: toks[yi], tradeMk: toks[yi + 1] || "", tradeMdl: toks.slice(yi + 2).join(" ") };
};
// Read the trade off a lead (or a New-Lead form object) — always structured.
const crmTradeOf = (l) => {
l = l || {};
const hasStruct = !!(l.tradeYr || l.tradeMk || l.tradeMdl);
const legacy = hasStruct ? {} : crmTradeSplit(l.tradeIn || l.tradeVehicle || "");
const up = (v) => String(v == null ? "" : v).trim().toUpperCase();
return {
tradeYr: up(hasStruct ? l.tradeYr : legacy.tradeYr), tradeMk: up(hasStruct ? l.tradeMk : legacy.tradeMk), tradeMdl: up(hasStruct ? l.tradeMdl : legacy.tradeMdl),
tradeVin: up(l.tradeVin), tradeMiles: String(l.tradeMiles == null ? "" : l.tradeMiles).trim(),
tradePayoff: String(l.tradePayoff == null ? "" : l.tradePayoff).trim(), tradeLender: up(l.tradeLender),
tradeAllow: l.tradeAllow == null ? "" : l.tradeAllow, tradeAcv: l.tradeAcv == null ? "" : l.tradeAcv, tradeApprId: l.tradeApprId || "",
};
};
const crmTradeDesc = (l) => { const t = crmTradeOf(l); return [t.tradeYr, t.tradeMk, t.tradeMdl].filter(Boolean).join(" "); };
const crmTradeHas = (l) => { const t = crmTradeOf(l); return !!(t.tradeYr || t.tradeMk || t.tradeMdl || t.tradeVin || (+String(t.tradePayoff).replace(/[^0-9.]/g, "") > 0)); };
const crmNum = (v) => +String(v == null ? "" : v).replace(/[^0-9.]/g, "") || 0;
const crmMoney = (v) => "$" + Math.round(crmNum(v)).toLocaleString("en-US");
// Normalize a trade patch so the joined string(s) always travel with the parts.
const crmTradePatch = (base, patch) => {
// normalize the base FIRST (its legacy string is split once) so clearing
// the structured fields never re-hydrates them from the old tradeIn text
const merged = crmTradeOf({ ...crmTradeOf(base), ...(patch || {}) });
const desc = [merged.tradeYr, merged.tradeMk, merged.tradeMdl].filter(Boolean).join(" ");
return { ...merged, tradeIn: desc, tradeVehicle: desc };
};
// Trade fields carried onto the deal at Desk It (allowance / ACV only when an
// appraisal set them — otherwise the desk manager decides the allowance).
const crmTradeForDeal = (l) => {
const t = crmTradeOf(l);
const out = { tradeDesc: crmTradeDesc(l), tradePayoff: crmNum(t.tradePayoff), tradeLender: t.tradeLender, tradeVin: t.tradeVin,
tradeMiles: crmNum(t.tradeMiles), tradeApprId: t.tradeApprId };
if (t.tradeApprId) { out.tradeAllow = crmNum(t.tradeAllow); out.tradeACV = crmNum(t.tradeAcv); out.tradeAcv = crmNum(t.tradeAcv); }
return out;
};
// ── Sales features (lane 2): trade-in requests, Desk It seller, claim clock ──
// Role class — a verbatim copy of the server's salesroles.salesRoleClass ('sales' | 'manager' | 'other').
const crmTreRoleClass = (role) => {
const r = String(role || '').trim();
if (!r) return 'other';
if (/admin/i.test(r)) return 'manager';
if (/^sales$/i.test(r)) return 'sales';
if (/^((sales|general|gm)\s*)?(manager|mgr)$|^f\s*&\s*i(\s*manager)?$|^finance(\s*manager)?$|^sales\s*director$|^owner$/i.test(r)) return 'manager';
return 'other';
};
const crmTreIsSales = () => !!(window.DMS && window.DMS.roleIs && window.DMS.roleIs("Sales"));
const crmTreIsMgr = () => !!(window.DMS && ((window.DMS.isAdmin && window.DMS.isAdmin()) || (window.DMS.roleIs && window.DMS.roleIs("Sales Manager"))));
// Desk It: who sold it. 1) the roster user whose name is the lead's salesperson (exactly one match);
// 2) else the lead's creator (_owner) when that login is a sales or manager login; 3) else blank.
// (lead.salesperson = who the lead is ASSIGNED to; lead._owner = who CREATED it, often a manager.)
const crmTreSeller = (lead) => {
const roster = (window.DMS && window.DMS.roster) ? window.DMS.roster() : [];
const typed = String((lead && lead.salesperson) || "").trim();
const nm = typed.toLowerCase();
if (nm) {
const hits = roster.filter(u => String(u.name || "").trim().toLowerCase() === nm);
if (hits.length === 1) return { spId: String(hits[0].id), salesperson: hits[0].name || String(hits[0].id) };
}
const ow = String((lead && lead._owner) || "").trim();
if (ow) {
const u = roster.find(x => String(x.id) === ow);
if (u) { const c = crmTreRoleClass(u.role); if (c === "sales" || c === "manager") return { spId: String(u.id), salesperson: u.name || String(u.id) }; }
}
return { spId: "", salesperson: typed };
};
// A trade the server is working on (Trade-In Requests): the valued car cannot change under the offer.
const crmTreLocked = (l) => !!l && (l.tradeStatus === "submitted" || l.tradeStatus === "valued");
// CRM -> Trade-In Requests prefill (read once there).
const crmTreHandoff = (lead) => {
const t = crmTradeOf(lead);
const ptr = { leadId: lead.id || "", custNum: lead.custNum || "", name: lead.name || "", vin: t.tradeVin, miles: t.tradeMiles,
year: t.tradeYr, make: t.tradeMk, model: t.tradeMdl, payoff: crmNum(t.tradePayoff), lender: t.tradeLender };
try { localStorage.setItem("jb_trade_lead", JSON.stringify(ptr)); } catch (e) {}
return ptr;
};
// The salesperson's trade chip: never an ACV (the server does not send it to Sales anyway).
const CrmTreChip = ({ lead }) => {
const st = lead.tradeStatus || "";
const base = { fontSize: 10.5, fontWeight: 700, textTransform: "none", letterSpacing: 0, padding: "2px 9px", borderRadius: 999, border: "1px solid" };
if (st === "submitted") return Waiting for a manager ;
if (st === "valued") return Offer {crmMoney(lead.tradeAllow)}{lead.tradeOfferBy ? " - " + lead.tradeOfferBy : ""} ;
if (st === "declined") return Not taken ;
if (st === "withdrawn") return Withdrawn ;
if (lead.tradeApprId && lead.tradeAllow) return Allowance {crmMoney(lead.tradeAllow)} ;
return null;
};
// "You claimed this customer - N days left" (GET /customer-owners/claims: the caller's claims).
const useCrmTreClaims = (enabled) => {
const [claims, setClaims] = React.useState(null); // null = unknown / feature off
const load = React.useCallback(async () => {
if (!enabled || typeof apiFetch !== "function") return;
try { const r = await apiFetch("/customer-owners/claims"); setClaims(r && r.ok && Array.isArray(r.claims) ? r.claims : null); }
catch (e) { setClaims(null); }
}, [enabled]);
React.useEffect(() => {
if (!enabled) return undefined;
load();
const onWs = (e) => { const m = e && e.detail; if (m && (m.type === "sales_owner" || (m.type === "sales_alert" && m.alert && /claim/.test(String(m.alert.kind || ""))))) load(); };
window.addEventListener("k2b-ws", onWs);
const t = setInterval(load, 120000);
return () => { window.removeEventListener("k2b-ws", onWs); clearInterval(t); };
}, [enabled, load]);
return { claims, reload: load };
};
// Audit A-09: keep a LOCAL draft while the field is focused and commit to the
// shared DMS store on blur / Enter. Writing on every keystroke scheduled a
// server POST per character, and a remote merge / echo of the same lead could
// replace the record under the caret mid-word.
const CrmFld = ({ label, k, lead, upd, ph, mono, up }) => {
const [draft, setDraft] = React.useState(null); // null = not editing
React.useEffect(() => { setDraft(null); }, [lead.id]); // switching leads drops any stale draft
const stored = lead[k] == null ? "" : String(lead[k]);
const commit = () => {
if (draft !== null && draft !== stored) upd(lead.id, { [k]: draft });
setDraft(null);
};
return (
{label}
setDraft(stored)}
onChange={(e) => setDraft(up ? e.target.value.toUpperCase() : e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); e.currentTarget.blur(); } }}
style={{ fontSize: 12.5, padding: "5px 7px", border: "1px solid #C8CCD2", borderRadius: 4, fontFamily: mono ? "var(--jb-mono)" : "var(--jb-font)" }} />
);
};
// ── Trade-In block (shared by the New Lead modal and the lead workspace).
// live=true → inputs are CrmFld (draft while focused, commit on blur) on
// the saved lead; `patch(obj)` writes to the store.
// live=false → controlled inputs on the New Lead form; `patch(obj)` merges
// into the local form state.
// Year / Make / Model side by side (uppercase), Payoff $, Payoff lender, and
// the two doors to the appraisal desk: ⟳ Load from Appraisal (pick a saved
// one) and Trade Appraisal ▸ (run one for this lead — it writes back here).
const CrmTradeBlock = ({ vals, patch, live, onLoad, onAppraise, compact, sales, onSubmitTrade, submitId }) => {
const t = crmTradeOf(vals);
const has = crmTradeHas(vals);
const lead = { ...vals, ...t, id: vals.id || "new" };
// Sales: a trade the server is working on keeps its vehicle (the server keeps the stored values anyway).
const locked = !!sales && crmTreLocked(vals);
const upd = (_id, p) => patch(p);
// A 17-character VIN typed / pasted into the trade fills the still-blank Year / Make / Model from our server (MarketCheck -> NHTSA ->
// the VIN itself; window.DMS.vin). A typed year / make / model is never overwritten; Undo puts the filled values back.
const vinFill = (typeof VlUseVinFill === "function") ? VlUseVinFill({
vin: t.tradeVin, enabled: !locked, keys: { tradeYr: "year", tradeMk: "make", tradeMdl: "model" },
read: () => crmTradeOf(vals), write: (p) => patch(p), shape: { tradeYr: (n) => String(n) },
}) : null;
const inp = { width: "100%", boxSizing: "border-box", fontSize: compact ? 12.5 : 13, padding: compact ? "5px 7px" : "7px 9px", border: "1px solid #C8CCD2", borderRadius: 4 };
const lbl = { fontSize: compact ? 10.5 : 11, fontWeight: compact ? 700 : 600, color: compact ? "var(--jb-text-muted)" : "#666", textTransform: compact ? "uppercase" : "none", letterSpacing: compact ? 0.4 : 0 };
const col = { display: "flex", flexDirection: "column", gap: compact ? 2 : 3 };
// Plain function (NOT a nested component type) so CrmFld keeps its
// identity across renders — an inline component would remount the input
// on every keystroke and drop focus / the draft.
const F = ({ label, k, mono, up, ph }) => (locked && /^trade(Yr|Mk|Mdl|Vin|Miles)$/.test(k))
? {label}
: live
?
: {label}
patch({ [k]: up ? e.target.value.toUpperCase() : e.target.value })}
style={{ ...inp, fontFamily: mono ? "var(--jb-mono)" : "var(--jb-font)" }} /> ;
return (
Trade-In
{sales ?
: t.tradeApprId ? (
Appraised · ACV {crmMoney(t.tradeAcv)} · Allowance {crmMoney(t.tradeAllow)}
) : !has ? (
No trade
) : null}
{sales ? (
!crmTreLocked(vals) && onSubmitTrade
?
Submit trade for a value ▸
: null
) : <>
⟳ Load from Appraisal
Trade Appraisal ▸
>}
{locked &&
Withdraw the trade on Trade-In Requests to change the vehicle.
}
{sales && vals.tradeStatus === "valued" && vals.tradeOfferNote ?
Manager's note: “{vals.tradeOfferNote}”
: null}
{sales && vals.tradeStatus === "declined" && vals.tradeOfferNote ?
Not taken: “{vals.tradeOfferNote}”
: null}
{F({ label: "Year", k: "tradeYr", up: true, ph: "2019" })}
{F({ label: "Make", k: "tradeMk", up: true, ph: "CHEVROLET" })}
{F({ label: "Model", k: "tradeMdl", up: true, ph: "EQUINOX" })}
{F({ label: "Payoff $", k: "tradePayoff", mono: true, ph: "0" })}
{F({ label: "Payoff lender", k: "tradeLender", up: true, ph: "GM FINANCIAL" })}
{F({ label: "VIN (optional)", k: "tradeVin", mono: true, up: true })}
{F({ label: "Miles (optional)", k: "tradeMiles", mono: true })}
{vinFill && typeof VlVinNote === "function" &&
}
);
};
// ── ⟳ Load from Appraisal — pick a saved Trade Appraisal (newest first).
const CrmApprPicker = ({ onClose, onPick }) => {
const [q, setQ] = React.useState("");
const all = (window.DMS && window.DMS.appraisals) ? window.DMS.appraisals() : [];
const qq = q.trim().toLowerCase();
const list = all.filter(a => !qq || [a.id, a.year, a.make, a.model, a.vin, a.mileage, a.appraiser, a.color].join(" ").toLowerCase().includes(qq) || (window.DMS && window.DMS.custQueryHit ? window.DMS.custQueryHit(qq, a.custNum) : false)); // RC31 (fx31, F6): the appraisal of an account by an old number / a phone
const th = { padding: "6px 8px", textAlign: "left", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: 0.3, color: "var(--jb-text-muted)", borderBottom: "1px solid var(--jb-border)" };
const td = { padding: "6px 8px", fontSize: 12.5, borderBottom: "1px solid var(--jb-surface-2, #F0F2F5)", whiteSpace: "nowrap" };
return (
Cancel >}>
setQ(e.target.value)} autoFocus placeholder="🔍 Search year, make, model, VIN, appraisal #…"
style={{ width: "100%", boxSizing: "border-box", fontSize: 13, padding: "8px 10px", border: "1px solid #C8CCD2", borderRadius: 4, marginBottom: 10 }} />
{all.length === 0 ? (
No appraisals saved yet — run Trade Appraisal ▸
) : list.length === 0 ? (
No appraisals match '{q}' · setQ("")} style={{ marginLeft: 8 }}>Clear search
) : (
Date Vehicle Mileage ACV Allowance Payoff
{list.map(a => (
onPick(a)} style={{ cursor: "pointer" }} title={a.id + (a.appraiser ? " · " + a.appraiser : "")}>
{a.created ? new Date(a.created).toLocaleDateString() : "—"}
{[a.year, a.make, a.model].filter(Boolean).join(" ") || a.id}{a.vin ? {a.vin} : null}
{a.mileage ? crmNum(a.mileage).toLocaleString("en-US") + " mi" : "—"}
{crmMoney(a.acv)}
{crmMoney(a.allowance)}
{crmMoney(a.payoff)}
Load ▸
))}
)}
);
};
// Patch built from a picked appraisal — payoff only fills a blank one.
const crmTradeFromAppr = (cur, a) => {
const t = crmTradeOf(cur);
const p = { tradeYr: String(a.year || ""), tradeMk: String(a.make || ""), tradeMdl: String(a.model || ""), tradeVin: String(a.vin || ""),
tradeMiles: String(a.mileage || ""), tradeAllow: crmNum(a.allowance), tradeAcv: crmNum(a.acv), tradeApprId: a.id };
if (!crmNum(t.tradePayoff) && crmNum(a.payoff)) p.tradePayoff = String(crmNum(a.payoff));
return crmTradePatch(cur, p);
};
// Hand-off pointer the Trade Appraisal screen reads once (then removes).
const crmAppraisePointer = (lead) => {
const t = crmTradeOf(lead);
const ptr = { leadId: lead.id || "", custNum: lead.custNum || "", name: lead.name || "", year: t.tradeYr, make: t.tradeMk, model: t.tradeMdl,
vin: t.tradeVin, miles: t.tradeMiles, payoff: crmNum(t.tradePayoff) };
try { localStorage.setItem("jb_appraisal_lead", JSON.stringify(ptr)); } catch (e) {}
window.jbAppraisalLead = ptr;
return ptr;
};
const CRM = ({ onClose, go }) => {
const [, force] = React.useReducer(n => n + 1, 0);
React.useEffect(() => {
const h = () => force();
const hs = (e) => { if (e.key === CRM_KEY) force(); };
window.addEventListener("crm-change", h); window.addEventListener("storage", hs);
// live updates from the shared core — other stations' leads appear as they're entered
const unsub = (window.DMS && window.DMS.subscribe) ? window.DMS.subscribe(force) : null;
return () => { window.removeEventListener("crm-change", h); window.removeEventListener("storage", hs); if (unsub) unsub(); };
}, []);
const toast = (m, tone) => window.k2bToast && window.k2bToast(m, tone || "ok", 3400);
const leads = crmLoad();
const [selId, setSelId] = React.useState(null);
const [q, setQ] = React.useState("");
const [fStatus, setFStatus] = React.useState("Active");
const [fSp, setFSp] = React.useState("All");
const [showNew, setShowNew] = React.useState(false);
const [presetCust, setPresetCust] = React.useState(null);
const [pickUnit, setPickUnit] = React.useState(false);
// Hand-off from Customers / Scan DL: jb_crm_cust = custNum → open New Lead
// with that customer preselected (read-once, then removed).
React.useEffect(() => {
try {
const cn = localStorage.getItem("jb_crm_cust");
if (cn) { localStorage.removeItem("jb_crm_cust"); setPresetCust(cn); setShowNew(true); }
} catch (e) {}
}, []);
const upd = (id, patch) => {
const all = crmLoad(); const i = all.findIndex(l => l.id === id); if (i < 0) return;
// any trade part edited → keep the joined tradeIn / tradeVehicle string in step
if (patch && CRM_TRADE_KEYS.some(k => k in patch)) patch = crmTradePatch(all[i], patch);
all[i] = { ...all[i], ...patch, lastTouch: new Date().toISOString() }; crmSave(all);
};
const addAct = (id, kind, body) => { const all = crmLoad(); const l = all.find(x => x.id === id); if (!l) return; l.activities = [{ kind, body, at: new Date().toISOString() }, ...(l.activities || [])]; l.lastTouch = new Date().toISOString(); crmSave(all); };
const setTask = (id, taskId, patch) => { const all = crmLoad(); const l = all.find(x => x.id === id); if (!l) return; l.tasks = (l.tasks || []).map(t => t.id === taskId ? { ...t, ...patch } : t); crmSave(all); };
const addTask = (id, label, days) => { const all = crmLoad(); const l = all.find(x => x.id === id); if (!l) return; const d = new Date(); d.setDate(d.getDate() + (+days || 0)); l.tasks = [...(l.tasks || []), { id: "T" + Date.now().toString(36), label, due: d.toISOString(), kind: "note", done: false }]; crmSave(all); toast("Follow-up scheduled."); };
// salespeople: SERVER roster first (users created in Settings → Users
// (Server) — Sales roles plus Admins), then the legacy local Users store,
// de-duped by id/name. Only the data source changed — the UI is identical.
// DMS.roster() is cached in DMSCore (boot + 5-min refresh) and this
// component already subscribes to DMS, so new server users appear live.
const salespeople = (() => {
const seen = new Set(); const out = [];
const add = (label, id) => {
const name = String(label || "").trim();
if (!name) return;
const keys = [name.toLowerCase()].concat(id != null && String(id).trim() !== "" ? ["#" + String(id).trim().toLowerCase()] : []);
if (keys.some(k => seen.has(k))) return;
keys.forEach(k => seen.add(k));
out.push(name);
};
const roster = (window.DMS && window.DMS.roster) ? window.DMS.roster() : [];
roster.filter(u => /sales/i.test(u.role || "") || /admin/i.test(u.role || ""))
.forEach(u => add(u.name || u.id, u.id));
(window.jbUsers ? window.jbUsers.list() : [])
.filter(u => /sales/i.test(u.role || ""))
.forEach(u => add(u.name || u.username, u.id));
return out;
})();
const spOptions = ["All", ...salespeople];
// reminders — due today / overdue across open leads
const openLeads = leads.filter(l => l.status !== "Sold" && l.status !== "Lost");
const allTasks = openLeads.flatMap(l => (l.tasks || []).filter(t => !t.done).map(t => ({ ...t, lead: l })));
const overdue = allTasks.filter(t => crmDue(t) < 0);
const dueToday = allTasks.filter(t => crmDue(t) === 0);
const remindedRef = React.useRef(false);
React.useEffect(() => {
if (remindedRef.current || (!overdue.length && !dueToday.length)) return;
remindedRef.current = true;
toast(`⏰ Follow-ups: ${overdue.length} overdue · ${dueToday.length} due today.`, overdue.length ? "warn" : "ok");
}, []);
const soldMtd = leads.filter(l => l.status === "Sold" && l.lastTouch && new Date(l.lastTouch).getMonth() === new Date().getMonth()).length;
const qq = q.trim().toLowerCase();
const list = leads.filter(l => {
if (fSp !== "All" && l.salesperson !== fSp) return false;
if (fStatus === "Active" && (l.status === "Sold" || l.status === "Lost")) return false;
if (fStatus !== "Active" && fStatus !== "All" && l.status !== fStatus) return false;
if (!qq) return true;
return [l.name, l.phone, l.email, l.intMk, l.intMdl, l.intStock, l.source, crmTradeDesc(l)].join(" ").toLowerCase().includes(qq) || (window.DMS && window.DMS.custQueryHit ? window.DMS.custQueryHit(qq, l.custNum, [l.phone]) : false); // RC31 (fx31, F6): the lead of an account by an old number / a phone in any format
}).sort((a, b) => new Date(b.lastTouch || b.created) - new Date(a.lastTouch || a.created));
const sel = leads.find(l => l.id === selId) || null;
// Audit C-01 / ONE PATH: exact-role fence. Sales (and blank / offline /
// unknown roles) cannot desk a lead — the server refuses deal writes for
// Sales. The button is hidden and this is the last-line guard.
const canCreateDeals = crmCanDesk();
const dealOf = (id) => (id && window.DMS && window.DMS.deals ? window.DMS.deals() : []).find(d => d.id === id) || null;
// Hand-off pointers Desking / Cash Deal / Finance Deal read (contract).
const setDeskPointers = (id, buyer, stock) => {
const D = window.DMS;
window.jbDeskDeal = { id, buyer };
try { localStorage.setItem("jb_desk_deal", JSON.stringify({ id, buyer })); localStorage.setItem("jb_fw_deal", id); } catch (e) {}
const unit = (stock && D && D._unit) ? D._unit(stock) : null;
if (unit) {
const u = { stock: unit.stock, vin: unit.vin, yr: unit.yr, mk: unit.mk, mdl: unit.mdl, list: unit.asking, cost: unit.cost, type: unit.type, miles: unit.miles };
window.jbDeskUnit = u;
try { localStorage.setItem("jb_desk_unit", JSON.stringify(u)); } catch (e) {}
} else {
try { delete window.jbDeskUnit; localStorage.removeItem("jb_desk_unit"); } catch (e) {}
}
};
// DESK IT — the ONLY place a deal is born. Creates the deal from the lead
// (stage "Desked", linked by leadId / custNum), writes the pointers and
// opens Desking. An already-desked lead just re-opens its deal.
const deskIt = (lead) => {
if (!canCreateDeals) { toast("Ask a manager to desk this lead.", "warn"); return; }
const D = window.DMS;
if (!D || !D.addDeal) { toast("DMS core not loaded — cannot desk.", "warn"); return; }
// Trade-in travels with the deal (owner: "it doesn't transfer trade from
// the CRM to the desking tool"). tradeAllow / tradeACV only when an
// appraisal set them — otherwise the desk manager sets the allowance.
const trade = crmTradeForDeal(lead);
const hasTrade = crmTradeHas(lead);
const tradeNote = hasTrade ? ` (trade: ${trade.tradeDesc || trade.tradeVin || "trade"}${trade.tradePayoff ? ", payoff " + crmMoney(trade.tradePayoff) : ""})` : "";
if (lead.dealId) {
const d = dealOf(lead.dealId);
// A trade added in the CRM AFTER desking still reaches the desk: patch
// the existing deal when its trade description is blank.
if (d && hasTrade && !String(d.tradeDesc || d.tradeYrMkMdl || d.tradeVeh || "").trim() && D.updateDeal) {
const patch = { ...trade };
if (+d.tradePayoff > 0 && !patch.tradePayoff) delete patch.tradePayoff;
const pr = D.updateDeal(lead.dealId, patch);
if (pr && pr.ok) addAct(lead.id, "note", "Trade sent to desk " + lead.dealId + tradeNote);
}
setDeskPointers(lead.dealId, d ? d.buyer : String(lead.name || "").toUpperCase(), d ? d.stock : lead.intStock);
go && go("desking"); return;
}
const c = (D.customers ? D.customers() : {})[lead.custNum] || {};
const buyer = String(lead.name || [c.lastName, c.firstName].filter(Boolean).join(", ")).toUpperCase();
if (!buyer) { toast("Lead has no customer name.", "warn"); return; }
const seller = crmTreSeller(lead); // sales features: spId = who sold it (alerts follow the seller)
const r = D.addDeal({
buyer, custNum: lead.custNum || "", phone: lead.phone || c.phone || "", email: lead.email || c.email || "",
street: c.street || "", city: c.city || "", state: c.state || "", postal: c.postal || c.zip || "",
vehicle: [lead.intYr, lead.intMk, lead.intMdl].filter(Boolean).join(" "), stock: lead.intStock || "",
salesperson: seller.salesperson, spId: seller.spId, leadId: lead.id, stage: "Desked",
...(hasTrade ? trade : {}),
});
if (!r.ok) { toast(r.msg, "warn"); return; }
upd(lead.id, { dealId: r.id, status: lead.status === "New" ? "Working" : lead.status });
addAct(lead.id, "note", "Desked as " + r.id + tradeNote);
setDeskPointers(r.id, buyer, lead.intStock);
toast(`Deal ${r.id} desked from this lead${hasTrade ? " — trade carried" : ""} — opening Desking.`);
go && go("desking");
};
// Trade-in hand-offs (workspace card): pick a saved appraisal, or run one.
const [apprPickFor, setApprPickFor] = React.useState(null); // lead id being filled from an appraisal
const loadAppraisal = (lead, a) => {
upd(lead.id, crmTradeFromAppr(lead, a));
addAct(lead.id, "note", "Trade loaded from appraisal " + a.id + " — " + [a.year, a.make, a.model].filter(Boolean).join(" ") + " · ACV " + crmMoney(a.acv) + " · allowance " + crmMoney(a.allowance));
setApprPickFor(null);
toast("Trade loaded from appraisal " + a.id + ".");
};
const appraiseLead = (lead) => {
crmAppraisePointer(lead);
go && go("appraisal");
};
// Sales features: salespeople send the trade to a manager (Trade-In Requests) instead of valuing it.
const salesTrades = crmTreIsSales();
const submitTradeFor = (lead) => {
if (!lead) return;
crmTreHandoff(lead);
go && go("trades");
};
const { claims: myClaims, reload: reloadClaims } = useCrmTreClaims(crmTreIsSales() || crmTreIsMgr());
const openDeal = (id) => {
if (!canCreateDeals || !id) return;
try { localStorage.setItem("jb_deal_focus", id); } catch (e) {}
go && go("deal");
};
return (
{/* KPI strip + filters */}
{[["Open Leads", openLeads.length, "var(--jb-navy)"], ["Overdue Follow-Ups", overdue.length, overdue.length ? "var(--jb-red)" : "var(--jb-green)"],
["Due Today", dueToday.length, dueToday.length ? "var(--jb-amber, #9A6700)" : "var(--jb-green)"],
["Appts Set", leads.filter(l => l.status === "Appt Set").length, "#8050B0"], ["Sold MTD", soldMtd, "var(--jb-green)"]].map(([l, v, c], i) => (
))}
setFSp(e.target.value)} style={{ fontSize: 12 }} title="Salesperson">
{spOptions.map(s => {s} )}
setShowNew(true)}>+ New Lead
{/* queue */}
setQ(e.target.value)} placeholder="🔍 Search name, phone, vehicle…"
style={{ width: "100%", boxSizing: "border-box", fontSize: 12.5, padding: "6px 8px", border: "1px solid #C8CCD2", borderRadius: 4 }} />
{["Active", "All", ...CRM_STATUSES].map(s => (
setFStatus(s)} style={{ fontSize: 10, fontWeight: 700, padding: "1px 7px", borderRadius: 999, cursor: "pointer",
border: "1px solid " + (fStatus === s ? "var(--jb-navy)" : "#D5DAE0"), background: fStatus === s ? "var(--jb-navy)" : "#fff", color: fStatus === s ? "#fff" : "#667" }}>{s}
))}
{list.length === 0 && (
Every customer and every deal starts here: + New Lead → find or add the customer → Desk It .
)}
{list.map(l => {
const on = l.id === selId;
const od = (l.tasks || []).filter(t => !t.done && crmDue(t) < 0).length;
return (
setSelId(l.id)} style={{ padding: "9px 12px", borderBottom: "1px solid var(--jb-border)", cursor: "pointer",
background: on ? "var(--jb-blue-tint, #E5EFFA)" : "transparent", borderLeft: on ? "3px solid var(--jb-blue)" : "3px solid transparent" }}>
{[l.intYr, l.intMk, l.intMdl].filter(Boolean).join(" ") || "no vehicle yet"} · {l.source}{l.salesperson ? " · " + l.salesperson : ""}
{crmTradeHas(l) ? " · trade: " + ([crmTradeOf(l).tradeYr, crmTradeOf(l).tradeMdl || crmTradeOf(l).tradeMk].filter(Boolean).join(" ") || "yes") : ""}
touched {crmDays(l.lastTouch || l.created)}d ago
{od > 0 && ⏰ {od} overdue }
{l.dealId && → {l.dealId} }
);
})}
{/* workspace */}
{!sel ? (
Every customer and every deal starts here:
+ New Lead → find or add the customer →
Desk It .
Select a lead on the left — the right side is the salesperson's whole desk: contact, vehicle, every touch, and the scheduled follow-ups.
) : (
{/* header + status pipeline */}
{sel.name}
{sel.dealId && (() => {
const d = dealOf(sel.dealId);
const stage = d && window.DMS && window.DMS.dealStage ? window.DMS.dealStage(d) : "Desked";
return (
openDeal(sel.dealId)}
title={canCreateDeals ? "Open in Deal Pipeline" : "Deal opened by a manager"}
style={{ fontFamily: "var(--jb-mono)", fontSize: 11, fontWeight: 700, padding: "2px 9px", borderRadius: 999,
background: "var(--jb-green-tint, #E6F4EA)", color: "var(--jb-green, #16A34A)", border: "1px solid var(--jb-green, #16A34A)",
cursor: canCreateDeals ? "pointer" : "default" }}>
{sel.dealId} · {stage}
);
})()}
{sel.source} lead · created {new Date(sel.created).toLocaleDateString()} · {sel.salesperson || "unassigned"}
{/* Credit application lives ON the customer record — Sales can fill it
here before a manager desks the deal. */}
{sel.custNum && (() => {
const cc = (window.DMS && window.DMS.customers && window.DMS.customers()[sel.custNum]) || null;
const pct = (cc && window.jbCreditCompleteness) ? window.jbCreditCompleteness(cc).pct : null;
return (
{ try { localStorage.setItem("jb_credit_cust", sel.custNum); } catch (e) {} go && go("creditapp"); }}
title="Employment, SSN, DOB, residence — the credit application on this customer's file">
✍ Credit App{pct != null ? " · " + pct + "%" : ""} ▸
);
})()}
{/* ONE PATH / audit C-01: Desk It is the only door to a deal and
is fenced off for the Sales role (the server refuses
addDeal for Sales). */}
{canCreateDeals ? (
deskIt(sel)}>
{sel.dealId ? "Open Desk " + sel.dealId + " ▸" : "Desk It ▸"}
) : (
<>
{/* Sales: read-only customer view of the saved desk of THEIR OWN deal (DeskingTool.jsx.txt) */}
{window.DkfViewDeskButton ? : null}
Ask a manager to desk this lead
>
)}
{CRM_STATUSES.map(s => (
{ upd(sel.id, { status: s }); addAct(sel.id, "note", "Status → " + s); }}
style={{ fontSize: 11, fontWeight: 700, padding: "3px 10px", borderRadius: 999, cursor: "pointer",
border: "1px solid " + (sel.status === s ? CRM_TONE[s] : "#D5DAE0"),
background: sel.status === s ? CRM_TONE[s] : "#fff", color: sel.status === s ? "#fff" : "#667" }}>{s}
))}
{(() => {
// Sales features: a customer this person claimed from the orphan pool stays theirs while they log contact.
const meId = (window.DMS && window.DMS.userId) ? String(window.DMS.userId()) : "";
const cl = sel.custNum && Array.isArray(myClaims) ? myClaims.find(x => String(x.custKey) === String(sel.custNum) && String(x.userId) === meId) : null;
if (!cl) return null;
const d = Math.max(0, +cl.daysLeft || 0);
return (
🧲 You claimed this customer - {d} day{d === 1 ? "" : "s"} left. Log a call, text, email or visit to keep them.
);
})()}
{/* contact + vehicle */}
Contact
Customer # {sel.custNum || "—"}
{ try { if (sel.custNum) localStorage.setItem("jb_cust_focus", sel.custNum); } catch (e) {} go && go("customers"); }}>Open customer file ▸
Salesperson
upd(sel.id, { salesperson: e.target.value })} style={{ fontSize: 12.5, padding: "5px 7px" }}>
— assign —
{salespeople.map(s => {s} )}
Vehicle Interest
setPickUnit(true)}>🔍 Pick from Inventory
Trade-in: {crmTradeDesc(sel) || (crmTradeHas(sel) ? "trade on file" : "none")} — edit under Deal History & Trade below.
{/* follow-ups */}
{/* deal history & trade — pulled live from the DMS ledger */}
Deal History & Trade
{(() => {
// custNum is THE key; name / legacy phone digits are only a
// fallback for deals that pre-date the customer file link.
const all = (window.DMS && window.DMS.deals ? window.DMS.deals() : []);
const cn = String(sel.custNum || "");
const digits = String(sel.phone || "").replace(/\D/g, "").slice(-7);
const nm = String(sel.name || "").toLowerCase().replace(/[^a-z]/g, "");
const custRec = cn && window.DMS && window.DMS.findCustomer ? window.DMS.findCustomer(cn) : null;
const keys = [cn].concat(custRec && Array.isArray(custRec.aliases) ? custRec.aliases.map(String) : []).filter(Boolean); // RC30 (cn): an old number on a deal still counts
let deals = cn ? all.filter(d => keys.indexOf(String(d.custNum || "")) >= 0) : [];
if (!deals.length) deals = all.filter(d =>
(digits && (String(d.custNum || "").replace(/\D/g, "").endsWith(digits) || String(d.phone || "").replace(/\D/g, "").endsWith(digits))) ||
(nm && String(d.buyer || "").toLowerCase().replace(/[^a-z]/g, "") === nm));
const stageOf = (d) => (window.DMS && window.DMS.dealStage) ? window.DMS.dealStage(d) : (d.postedToGL ? "Posted" : (d.status || "Working"));
return deals.length ? deals.map(d => (
openDeal(d.id)} title={canCreateDeals ? "Open in Deal Pipeline" : ""}
style={{ display: "flex", gap: 8, alignItems: "center", fontSize: 12, padding: "4px 0", borderBottom: "1px solid var(--jb-surface-2)", cursor: canCreateDeals ? "pointer" : "default" }}>
{d.id}
{d.vehicle || "—"}
{stageOf(d)}
{d.salePrice ? ${(+d.salePrice).toLocaleString()} : null}
)) :
No deals on file for this customer yet.
;
})()}
upd(sel.id, p)} sales={salesTrades}
onSubmitTrade={() => submitTradeFor(sel)}
onLoad={() => setApprPickFor(sel.id)} onAppraise={() => appraiseLead(sel)} />
{crmTradeHas(sel) && sel.dealId && (() => {
const d = dealOf(sel.dealId);
const onDesk = d && String(d.tradeDesc || d.tradeYrMkMdl || d.tradeVeh || "").trim();
return onDesk ? null : (
This trade isn't on deal {sel.dealId} yet — Open Desk {sel.dealId} ▸ sends it to the desk.
);
})()}
{/* activity log */}
Log a Touch
{ addAct(sel.id, kind, body); toast(CRM_ACT_ICON[kind] + " logged."); if (/^(call|text|email|visit)$/.test(kind)) setTimeout(reloadClaims, 2500); }} />
{(sel.activities || []).map((a, i) => (
{CRM_ACT_ICON[a.kind] || "📝"}
{a.body}
{new Date(a.at).toLocaleString()}
))}
{!(sel.activities || []).length &&
No touches logged yet — the first call goes here. }
)}
ESC-Close
go && go("customers")}>Customers
{canCreateDeals && go && go("deal")}>Deal Pipeline }
{showNew &&
{ setShowNew(false); setPresetCust(null); }}
onCreate={(f) => {
const all = crmLoad();
const id = "L-" + String(Date.now()).slice(-7);
const now = new Date();
const owner = (window.DMS && window.DMS.userId) ? window.DMS.userId() : "";
const tasks = CRM_CADENCE.map((c, i) => { const d = new Date(now); d.setDate(d.getDate() + c.day); return { id: "T" + Date.now().toString(36) + i, label: c.label, kind: c.kind, due: d.toISOString(), done: false }; });
const activities = [{ kind: "note", body: "Lead created (" + (f.source || "Walk-In") + ")", at: now.toISOString() }];
if (f.tradeApprId) activities.unshift({ kind: "note", body: "Trade loaded from appraisal " + f.tradeApprId, at: now.toISOString() });
all.unshift({ id, ...f, _owner: owner, status: "New", activities, tasks, created: now.toISOString(), lastTouch: now.toISOString() });
crmSave(all); setSelId(id); setShowNew(false); setPresetCust(null);
toast("Lead saved — " + CRM_CADENCE.length + " follow-ups auto-scheduled.");
return id;
}}
onAppraise={(leadId) => { const l = crmLoad().find(x => x.id === leadId); if (l) appraiseLead(l); }}
sales={salesTrades}
onSubmitTrade={(leadId) => { const l = crmLoad().find(x => x.id === leadId); if (l) submitTradeFor(l); }} />}
{apprPickFor && (() => {
const l = leads.find(x => x.id === apprPickFor);
return l ? setApprPickFor(null)} onPick={(a) => loadAppraisal(l, a)} /> : null;
})()}
{pickUnit && sel && window.UnitPickerModal && (
setPickUnit(false)}
onPick={(u) => { upd(sel.id, { intYr: String(u.yr || ""), intMk: String(u.mk || "").trim(), intMdl: String(u.mdl || ""), intStock: String(u.stock || "") }); setPickUnit(false); addAct(sel.id, "note", "Interested in Stk " + u.stock + " — " + [u.yr, u.mk, u.mdl].join(" ")); }} />
)}
);
};
const CrmComposer = ({ onLog }) => {
const [kind, setKind] = React.useState("call");
const [body, setBody] = React.useState("");
const send = () => { if (!body.trim()) return; onLog(kind, body.trim()); setBody(""); };
return (
{Object.keys(CRM_ACT_ICON).map(k => (
setKind(k)} style={{ fontSize: 11, fontWeight: 700, padding: "3px 9px", borderRadius: 999, cursor: "pointer",
border: "1px solid " + (kind === k ? "var(--jb-navy)" : "#D5DAE0"), background: kind === k ? "var(--jb-navy)" : "#fff", color: kind === k ? "#fff" : "#667" }}>{CRM_ACT_ICON[k]} {k}
))}
setBody(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()}
placeholder="What happened? (Enter to log)" style={{ flex: 1, minWidth: 200, fontSize: 12.5, padding: "6px 8px", border: "1px solid #C8CCD2", borderRadius: 4 }} />
Log
);
};
const CrmAddTask = ({ onAdd }) => {
const [label, setLabel] = React.useState("");
const [days, setDays] = React.useState("1");
return (
setLabel(e.target.value)} placeholder="+ custom reminder…"
style={{ flex: 1, fontSize: 12, padding: "5px 7px", border: "1px solid #C8CCD2", borderRadius: 4 }} />
in
setDays(e.target.value.replace(/[^0-9]/g, ""))} style={{ width: 34, fontSize: 12, padding: "5px 4px", border: "1px solid #C8CCD2", borderRadius: 4, textAlign: "center" }} />
d
{ if (label.trim()) { onAdd(label.trim(), days); setLabel(""); } }}>Add
);
};
// ── New Lead — TWO steps. Step 1 "Customer": search the customer file (or
// Scan DL / add a new customer right here — the ONLY place customers are
// created). Step 2 "Lead": source, salesperson, vehicle interest, trade.
const crmCustName = (c) => (window.CustNum && c && (Array.isArray(c.names) && c.names.length > 1) ? window.CustNum.displayName(c) : [c.lastName, c.firstName].filter(Boolean).join(", ")) || c.custNum || ""; // RC30 (cn): a household shows every name
const CrmNewLead = ({ salespeople, onClose, onCreate, onAppraise, presetCust, sales, onSubmitTrade }) => {
const custMap = () => (window.DMS && window.DMS.customers) ? window.DMS.customers() : {};
const toast = (m, tone) => window.k2bToast && window.k2bToast(m, tone || "ok", 3400);
const preset = presetCust ? custMap()[presetCust] : null;
const [step, setStep] = React.useState(preset ? 2 : 1);
const [cust, setCust] = React.useState(preset || null);
const [q, setQ] = React.useState("");
const [showNewCust, setShowNewCust] = React.useState(false);
const [nc, setNc] = React.useState({ firstName: "", lastName: "", phone: "", email: "", street: "", city: "", state: "MO", zip: "" });
const setN = (k, v) => setNc(s => ({ ...s, [k]: v }));
// Salesperson default: a signed-in Sales user gets their own roster name.
const defaultSp = (() => {
const D = window.DMS;
if (D && D.isRestricted && D.isRestricted() && D.roster && D.userId) {
const me = String(D.userId() || "").toLowerCase();
const hit = (D.roster() || []).find(u => String(u.id || "").toLowerCase() === me || String(u.name || "").toLowerCase() === me);
const nm = hit ? String(hit.name || hit.id || "").trim() : "";
if (nm && salespeople.includes(nm)) return nm;
}
return salespeople[0] || "";
})();
const [f, setF] = React.useState({ source: "Walk-In", salesperson: defaultSp, intYr: "", intMk: "", intMdl: "", intStock: "",
tradeYr: "", tradeMk: "", tradeMdl: "", tradeVin: "", tradeMiles: "", tradePayoff: "", tradeLender: "", tradeAllow: "", tradeAcv: "", tradeApprId: "",
tradeIn: "", tradeVehicle: "" });
const set = (k, v) => setF(s => ({ ...s, [k]: v }));
const [pick, setPick] = React.useState(false);
const [pickAppr, setPickAppr] = React.useState(false);
const patchTrade = (p) => setF(s => ({ ...s, ...crmTradePatch(s, p) }));
const inp = { width: "100%", boxSizing: "border-box", fontSize: 13, padding: "7px 9px", border: "1px solid #C8CCD2", borderRadius: 4 };
const lbl = { fontSize: 11, fontWeight: 600, color: "#666" };
const col = { display: "flex", flexDirection: "column", gap: 3 };
const select = (c) => { if (!c) return; setCust(c); setShowNewCust(false); setStep(2); };
// Scan DL is an INPUT METHOD for this form — app.jsx upserts the customer
// and fires k2b-customer-scanned {detail: customer}; we select it.
React.useEffect(() => {
const h = (e) => { const cn = e.detail && e.detail.custNum; if (!cn) return; select(custMap()[cn] || e.detail); };
window.addEventListener("k2b-customer-scanned", h);
return () => window.removeEventListener("k2b-customer-scanned", h);
}, []);
const qq = q.trim().toLowerCase();
const qd = q.replace(/\D/g, "");
const matches = !qq ? [] : Object.values(custMap()).filter(c => {
if (!c) return false;
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)
if (qd.length >= 3 && (String(c.custNum || "").includes(qd) || String(c.phone || "").replace(/\D/g, "").includes(qd) || (Array.isArray(c.aliases) && c.aliases.some((a) => String(a).replace(/\D/g, "").includes(qd))))) return true; // RC30 (cn): + an old number
const ln = String(c.lastName || "").toLowerCase(), fn = String(c.firstName || "").toLowerCase();
if (Array.isArray(c.names) && c.names.some((n) => n && (String(n.last || "").toLowerCase().startsWith(qq) || String(n.first || "").toLowerCase().startsWith(qq) || (String(n.last || "") + ", " + String(n.first || "")).toLowerCase().startsWith(qq)))) return true; // RC30 (cn): every person on a household account
return ln.startsWith(qq) || fn.startsWith(qq) || (ln + ", " + fn).startsWith(qq) || (fn + " " + ln).startsWith(qq) || (ln + " " + fn).startsWith(qq);
}).slice(0, 12);
// RC30 (cn): the customer # = the phone without the area code. No phone → K2B issues a number. The same 7 digits on file: the same full
// phone = one household (the name goes on that account); another area code = the office is asked (one account, or K2B issues a number) —
// never silently merged, never silently split.
const [askOffice, setAskOffice] = React.useState(null);
const saveNewCust = async (decision) => {
const D = window.DMS;
if (!nc.lastName.trim() && !nc.firstName.trim()) { toast("Customer name is required.", "warn"); return; }
if (!D || !D.addCustomer) { toast("DMS core not loaded.", "warn"); return; }
const up = (s) => String(s || "").trim().toUpperCase();
let ph10 = crmPhoneDigits(nc.phone);
if (nc.phone.trim() && ph10.length !== 10) { toast("A phone number needs 10 digits, like (573) 555-0123 — or leave it blank and K2B will issue a customer number.", "warn"); return; }
if (decision === "separate-nophone") ph10 = ""; // RC30 (cn) fix round (attacker L2): "not the same household" — the phone is another account's, so this person is saved without it (K2B issues a number)
let custNum = ph10.length === 10 ? crmCustNumOf(ph10) : "";
const base = { firstName: up(nc.firstName), lastName: up(nc.lastName), phone: ph10.length === 10 ? crmFmtPhone(ph10) : "", email: nc.email.trim(),
street: up(nc.street), city: up(nc.city), state: up(nc.state) || "MO", postal: nc.zip.trim(), zip: nc.zip.trim() };
if (custNum) {
const col = D.custNumCollision ? D.custNumCollision({ ...base, custNum }) : { kind: custMap()[custNum] ? "exists" : "ok" };
if (col.kind === "exists") { toast(`Customer # ${custNum} is already on file — selected.`, "info"); select(custMap()[custNum]); return; }
// RC30 (cn) fix round (attacker L2): the same full phone with a DIFFERENT last name is asked about before the name goes on the account
// (a household usually shares a surname; roommates, a recycled number or a typo join two people for good otherwise)
if (col.kind === "household" && !decision && window.CustNum) {
const ns = window.CustNum.namesOf(col.existing); const sameSurname = ns.some((n) => String(n.last || "").toUpperCase() === base.lastName);
if (!sameSurname) { setAskOffice({ kind: "surname", custNum, existing: col.existing, rec: base }); return; }
}
if (col.kind === "household" || decision === "household") {
const r = await D.addHouseholdName(custNum, { firstName: base.firstName, lastName: base.lastName });
if (!r.ok) { toast(r.msg || "Could not add the name.", "warn"); return; }
toast(r.msg || `${base.lastName}, ${base.firstName} added to account ${custNum} (same phone — one household).`); setAskOffice(null); select(custMap()[custNum]); return;
}
if (col.kind === "ask_office" && decision !== "separate") { setAskOffice({ custNum, existing: col.existing, rec: base }); return; }
if (decision === "separate") {
const iss = await D.issueCustNum({ reason: "area_code", collidesWith: custNum, name: `${base.lastName}, ${base.firstName}` });
if (!iss.ok) { toast(iss.msg, "warn"); return; }
custNum = iss.custNum;
}
} else {
if (decision !== "separate-nophone" && !confirm("No phone number. K2B will give this customer a number of its own (starting with 1). Continue?")) return;
const iss = await D.issueCustNum({ reason: "no phone", name: `${base.lastName}, ${base.firstName}` });
if (!iss.ok) { toast(iss.msg, "warn"); return; }
custNum = iss.custNum;
}
const rec = { custNum, ...base };
const r = D.addCustomer(rec);
if (!r.ok) {
if (r.code === "exists" && custMap()[custNum]) { toast(`Customer # ${custNum} is already on file — selected.`, "info"); select(custMap()[custNum]); return; }
toast(r.msg || "Could not add customer.", "warn"); return;
}
setAskOffice(null);
toast(`Customer # ${custNum} added.`);
select(custMap()[custNum] || rec);
};
const saveLead = (andAppraise, andSubmitTrade) => {
if (!cust) { setStep(1); toast("Pick the customer first.", "warn"); return; }
const trade = crmTradePatch(f, {}); // structured parts + joined tradeIn / tradeVehicle
const id = onCreate({
custNum: cust.custNum, name: crmCustName(cust).toUpperCase(), phone: cust.phone || cust.cell || "", email: cust.email || "", // RC30 (cn): a customer # is not a phone
source: f.source, salesperson: f.salesperson,
intYr: f.intYr, intMk: f.intMk, intMdl: f.intMdl, intStock: f.intStock,
...trade,
});
// "Trade Appraisal ▸" on a NEW lead: the lead is saved first (so the
// appraisal has something to write back to), then the desk opens.
if (andAppraise && id && onAppraise) onAppraise(id);
if (andSubmitTrade && id && onSubmitTrade) onSubmitTrade(id); // Sales: save the lead, then send its trade to a manager
};
const stepTab = (n, label) => (
{n} · {label}
);
return (
Cancel >
) : (
<> setStep(1)}>‹ Customer
Cancel
saveLead(false)}>Save Lead >
)}>
{stepTab(1, "Customer")}
→ {stepTab(2, "Lead")}
Customer # = the phone without the area code (7 digits)
{step === 1 && (
setQ(e.target.value)} autoFocus
placeholder="🔍 Search the customer file — phone digits, last name, or first name…"
style={{ ...inp, fontSize: 15, padding: "10px 12px", background: "#FFFEF4", border: "2px solid var(--jb-navy, #1B66A8)" }} />
{qq && (
{matches.length === 0 ? (
No customer matches — use + New Customer or Scan Driver's License below.
) : matches.map(c => (
select(c)}
style={{ display: "flex", gap: 10, alignItems: "center", padding: "7px 10px", cursor: "pointer", borderBottom: "1px solid var(--jb-surface-2, #F0F2F5)", fontSize: 12.5 }}>
{crmCustName(c)}
{c.phone || c.cell || ""} #{c.custNum}
{[c.city, c.state].filter(Boolean).join(", ")}
Select ▸
))}
)}
window.dispatchEvent(new CustomEvent("k2b-open-scan"))}>📷 Scan Driver's License
setShowNewCust(v => !v)}>+ New Customer
Not on file? Add them here — this is the only place customers are created.
{showNewCust && (
First setN("firstName", e.target.value)} autoFocus style={inp} />
Last setN("lastName", e.target.value)} style={inp} />
Phone (10 digits; the last 7 become the customer #)
setN("phone", e.target.value)} placeholder="(573) 555-0123" style={{ ...inp, fontFamily: "var(--jb-mono)" }} />
Email setN("email", e.target.value)} style={inp} />
Customer #
Street setN("street", e.target.value)} style={inp} />
City setN("city", e.target.value)} style={inp} />
State setN("state", e.target.value.toUpperCase().slice(0, 2))} style={inp} />
Zip setN("zip", e.target.value)} onKeyDown={(e) => e.key === "Enter" && saveNewCust()} style={inp} />
setShowNewCust(false)}>Cancel
saveNewCust()}>Save Customer & Continue ▸
{askOffice && askOffice.kind === "surname" && (
{crmFmtPhone(askOffice.rec.phone)} is already customer # {askOffice.custNum} — {crmCustName(askOffice.existing)} , a different last name. By the store's rule the people on one phone share one account (a household).
saveNewCust("household")}>Same household — add {askOffice.rec.lastName}, {askOffice.rec.firstName} to account {askOffice.custNum}
saveNewCust("separate-nophone")}>Different person — save WITHOUT this phone (K2B issues a number)
setAskOffice(null)}>Cancel — fix the phone
)}
{askOffice && askOffice.kind !== "surname" && (
Customer # {askOffice.custNum} is already {crmCustName(askOffice.existing)} ({askOffice.existing.phone || "phone on file"}) — this phone has another area code ({crmFmtPhone(askOffice.rec.phone)}). The office decides:
saveNewCust("household")}>Same household — add {askOffice.rec.lastName}, {askOffice.rec.firstName} to account {askOffice.custNum}
saveNewCust("separate")}>Two different people — K2B issues this one a number
setAskOffice(null)}>Cancel
)}
)}
)}
{step === 2 && cust && (
Source
set("source", e.target.value)} style={inp}>{CRM_SOURCES.map(s => {s} )}
Salesperson
set("salesperson", e.target.value)} style={inp}>— assign — {salespeople.map(s => {s} )}
Vehicle Interest
setPick(true)}>🔍 Pick from Inventory
Year set("intYr", e.target.value)} style={inp} />
Make set("intMk", e.target.value)} style={inp} />
Model set("intMdl", e.target.value)} style={inp} />
Stock # set("intStock", e.target.value)} style={{ ...inp, fontFamily: "var(--jb-mono)" }} />
saveLead(false, true)}
onLoad={() => setPickAppr(true)}
onAppraise={() => saveLead(true)} />
{sales
? Just enter year / make / model (payoff optional). Submit trade for a value ▸ saves this lead first, then opens Trade-In Requests to send the trade to a manager.
: Just enter year / make / model (payoff optional) — or ⟳ Load from Appraisal . Trade Appraisal ▸ saves this lead first, then opens the appraisal desk linked to it.
}
Saving auto-schedules the {CRM_CADENCE.length}-step follow-up cadence (same-day call → day-1 text → day-3 email → day-7 call → day-14 text → day-30 call). Next: Desk It on the lead.
)}
{pick && window.UnitPickerModal && (
setPick(false)}
onPick={(u) => { setF(s => ({ ...s, intYr: String(u.yr || ""), intMk: String(u.mk || "").trim(), intMdl: String(u.mdl || ""), intStock: String(u.stock || "") })); setPick(false); }} />
)}
{pickAppr && (
setPickAppr(false)}
onPick={(a) => { setF(s => ({ ...s, ...crmTradeFromAppr(s, a) })); setPickAppr(false); toast("Trade loaded from appraisal " + a.id + "."); }} />
)}
);
};
Object.assign(window, { CRM });