/* © 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 ( ); }; // ── 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)) ? : live ? : ; return (
Trade-In {sales ? : t.tradeApprId ? ( Appraised · ACV {crmMoney(t.tradeAcv)} · Allowance {crmMoney(t.tradeAllow)} ) : !has ? ( No trade ) : null}
{sales ? ( !crmTreLocked(vals) && onSubmitTrade ? : null ) : <> }
{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}' ·
) : (
{list.map(a => ( onPick(a)} style={{ cursor: "pointer" }} title={a.id + (a.appraiser ? " · " + a.appraiser : "")}> ))}
DateVehicleMileageACVAllowancePayoff
{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) => (
{l}
{v}
))}
{/* 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 => ( ))}
{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.name}
{l.status}
{[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 ( ); })()} {/* 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 ? ( ) : ( <> {/* 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 => ( ))}
{(() => { // 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 || "—"}
Vehicle Interest
Trade-in: {crmTradeDesc(sel) || (crmTradeHas(sel) ? "trade on file" : "none")} — edit under Deal History & Trade below.
{/* follow-ups */}
Follow-Ups & Reminders
{(sel.tasks || []).slice().sort((a, b) => new Date(a.due) - new Date(b.due)).map(t => { const d = crmDue(t); const col = t.done ? "#B9C0C9" : d < 0 ? "var(--jb-red, #C03546)" : d === 0 ? "#9A6700" : "var(--jb-text-dim)"; return ( ); })} {!(sel.tasks || []).length && No follow-ups scheduled.}
addTask(sel.id, label, days)} />
{/* 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 => ( ))} 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 }} />
); }; 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
); }; // ── 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 ▸
))}
)}
Not on file? Add them here — this is the only place customers are created.
{showNewCust && (
{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).
)} {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:
)}
)}
)} {step === 2 && cust && (
{crmCustName(cust)} {cust.phone || cust.cell || "no phone"} Customer # {cust.custNum}{cust.city ? " · " + [cust.city, cust.state].filter(Boolean).join(", ") : ""}
Vehicle Interest
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 });