/* eslint-disable */
/* © 2026 K2B NETWORK SOLUTIONS LLC. All rights reserved.
Proprietary and confidential. Unauthorized copying, distribution, or use of
this file, via any medium, is strictly prohibited. Original work product. */
// K2B DMS — Customer Ownership (sales features, lane 1). Route "custowners",
// menu Sales & F&I → Customer Ownership, sales managers only.
// 1. Orphan pool customers whose salesperson left (or who had none): give them to someone
// 2. Claimed orphans a salesperson claimed (30 days from the last logged contact)
// 3. Assigned by a manager
// 4. Old salesperson names printed names on old-DMS deals → a K2B login, or "left the dealership"
// 5. Leads of people who left
// All data comes from /api/customer-owners (the server decides whose customer
// is whose; this screen only shows it and sends a manager's decisions).
// Every top-level name is prefixed co / Co and exported through window.
const coIsMgr = () => (typeof window.slsIsMgr === "function" ? window.slsIsMgr()
: !!(window.DMS && ((DMS.isAdmin && DMS.isAdmin()) || (DMS.roleIs && DMS.roleIs("Sales Manager")))));
// Menu rule (app.jsx navAllowed asks window.jbNavRule first): managers only.
try { window.jbNavRule = window.jbNavRule || {}; window.jbNavRule.custowners = () => coIsMgr(); } catch (e) {}
const coApi = (path, opts) => (typeof apiFetch === "function" ? apiFetch(path, opts || {})
: Promise.reject(Object.assign(new Error("Not connected to the server."), { status: 0 })));
const coErr = (e, fb) => (e && e.body && e.body.error) || (e && e.status && e.message) || fb;
const coDay = (d) => (window.slsFmtDay ? window.slsFmtDay(d) : String(d || ""));
const coStamp = (iso) => (window.slsFmtStamp ? window.slsFmtStamp(iso) : (iso ? new Date(iso).toLocaleString() : ""));
const coLocalDay = (iso) => (window.slsFmtLocalDay ? window.slsFmtLocalDay(iso) : (iso ? new Date(iso).toLocaleDateString() : ""));
const coPhone = (p) => (window.slsFmtPhone ? window.slsFmtPhone(p) : String(p || ""));
const coToast = (m, k, ms) => { try { window.k2bToast && window.k2bToast(m, k || "ok", ms || 3600); } catch (e) {} };
const coRoleClass = (r) => (typeof window.slsRoleClass === "function" ? window.slsRoleClass(r) : (/^sales$/i.test(String(r || "").trim()) ? "sales" : "manager"));
const CO_TABS = [["pool", "Orphan pool"], ["claims", "Claimed"], ["assigned", "Assigned by a manager"], ["aliases", "Old salesperson names"], ["leads", "Leads of people who left"]];
// Active salespeople and managers (who can be given a customer / lead).
const coAssignable = (users) => {
const src = (users && users.length) ? users : ((window.DMS && DMS.roster && DMS.roster()) || []);
return src.filter((u) => u && u.id && u.active !== false && ["sales", "manager"].indexOf(coRoleClass(u.role)) >= 0)
.sort((a, b) => String(a.name || a.id).localeCompare(String(b.name || b.id)));
};
/* ── "Why?" dialog for a reassignment ── */
const CoReasonModal = ({ title, onCancel, onSave }) => {
const [reason, setReason] = React.useState("");
const [busy, setBusy] = React.useState(false);
const save = () => { if (!reason.trim() || busy) return; setBusy(true); onSave(reason.trim()); };
// the dialog's own buttons sit in the K2BModal footer (without them K2BModal adds an "OK" there that closes WITHOUT saving)
const buttons = (
<>
Cancel
Give customer
>
);
return (
Reason (the salesperson sees it)
setReason(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") save(); }}
placeholder="e.g. Called in asking for Mickie" style={{ padding: "6px 8px", fontSize: 13 }} />
);
};
/* ── a user picker ── */
const CoUserSelect = ({ users, value, onChange, attrs, placeholder }) => (
onChange(e.target.value)} {...(attrs || {})}
style={{ padding: "4px 6px", fontSize: 12.5, maxWidth: "100%", minWidth: 0 }}>
{placeholder || "Give to…"}
{users.map((u) => {u.name || u.id} )}
);
const coCard = { background: "var(--jb-surface)", border: "1px solid var(--jb-border)", borderRadius: 8, padding: "10px 12px",
display: "flex", flexWrap: "wrap", alignItems: "center", gap: "6px 14px", fontSize: 12.5, minWidth: 0, boxSizing: "border-box" };
const coMain = { flex: "1 1 220px", minWidth: 0, overflowWrap: "anywhere" };
const coDim = { color: "var(--jb-text-dim)", fontSize: 12 };
const coBadge = (bg, fg) => ({ display: "inline-block", padding: "1px 7px", borderRadius: 999, fontSize: 10.5, fontWeight: 800, background: bg, color: fg, whiteSpace: "nowrap" });
const CustomerOwnership = ({ onClose, go }) => {
const [me, setMe] = React.useState(undefined); // undefined = checking, null = features off
const [tab, setTab] = React.useState(() => {
try { const t = localStorage.getItem("jb_co_tab"); if (t) { localStorage.removeItem("jb_co_tab"); if (CO_TABS.some((x) => x[0] === t)) return t; } } catch (e) {}
return "pool";
});
const [data, setData] = React.useState({}); // tab → answer
const [loading, setLoading] = React.useState(false);
const [err, setErr] = React.useState("");
const [note, setNote] = React.useState("");
const [q, setQ] = React.useState("");
const [pick, setPick] = React.useState({}); // select values by key
const [reasonFor, setReasonFor] = React.useState(null); // {custKeys, toUser, toName}
const [checked, setChecked] = React.useState({}); // leads tab checkboxes
const [roster, setRoster] = React.useState([]);
const isMgr = coIsMgr();
React.useEffect(() => {
let alive = true;
const getMe = window.slsGetMe ? window.slsGetMe() : coApi("/sales-alerts/me").then((r) => (r && r.ok ? r : null), () => null);
getMe.then((m) => { if (alive) setMe(m || null); });
return () => { alive = false; };
}, []);
// the bell's "Match the name" while this screen is already open
React.useEffect(() => {
const h = (e) => { const t = e && e.detail && e.detail.tab; if (t) { try { localStorage.removeItem("jb_co_tab"); } catch (x) {} setTab(t); } };
window.addEventListener("sls-co-tab", h);
return () => window.removeEventListener("sls-co-tab", h);
}, []);
const can = !!(me && me.canManage && isMgr);
const load = React.useCallback(async (which, query) => {
if (!can) return;
setLoading(true); setErr("");
try {
let r;
if (which === "pool") r = await coApi("/customer-owners/pool?limit=200" + (query ? "&q=" + encodeURIComponent(query) : ""));
else if (which === "claims") r = await coApi("/customer-owners/claims");
else if (which === "assigned") r = await coApi("/customer-owners/assigned");
else if (which === "aliases") r = await coApi("/customer-owners/aliases");
else if (which === "leads") r = await coApi("/customer-owners/departed-leads");
setData((d) => Object.assign({}, d, { [which]: r || {} }));
if (which === "aliases" && r && Array.isArray(r.users)) setRoster(r.users);
} catch (e) {
setErr(coErr(e, "Could not load this list. Try again."));
}
setLoading(false);
}, [can]);
React.useEffect(() => { if (can) load(tab, tab === "pool" ? q : ""); }, [can, tab]);
// the full user list (includes logins that are turned off) for the pickers
React.useEffect(() => {
if (!can || roster.length) return;
coApi("/customer-owners/aliases").then((r) => { if (r && Array.isArray(r.users)) setRoster(r.users); }, () => {});
}, [can]);
// someone else's claim / reassignment / name match → refresh this tab (debounced)
React.useEffect(() => {
if (!can) return;
let t = null;
const h = (e) => {
const m = e && e.detail;
if (!m || (m.type !== "sales_owner" && e.type !== "sls-owner-changed")) return;
if (t) clearTimeout(t);
t = setTimeout(() => { t = null; load(tab, tab === "pool" ? q : ""); }, 1500);
};
window.addEventListener("k2b-ws", h);
window.addEventListener("sls-owner-changed", h);
return () => { if (t) clearTimeout(t); window.removeEventListener("k2b-ws", h); window.removeEventListener("sls-owner-changed", h); };
}, [can, tab, q, load]);
React.useEffect(() => {
if (tab !== "pool" || !can) return;
const t = setTimeout(() => load("pool", q), 350);
return () => clearTimeout(t);
}, [q]);
const assignable = coAssignable(roster);
const nameOf = (id) => { const u = roster.find((x) => x.id === id) || assignable.find((x) => x.id === id); return u ? (u.name || u.id) : id; };
const setPickFor = (k, v) => setPick((p) => Object.assign({}, p, { [k]: v }));
const doReassign = async (custKeys, toUser, reason) => {
setErr(""); setNote("");
try {
const r = await coApi("/customer-owners/reassign", { method: "POST", body: { custKeys, toUser, reason, moveLeads: true } });
const bad = ((r && r.results) || []).filter((x) => !x.ok);
if (bad.length) setErr(bad[0].error || "Some customers could not be given to " + nameOf(toUser) + ".");
else coToast((custKeys.length === 1 ? "Customer" : custKeys.length + " customers") + " given to " + nameOf(toUser) + ".", "ok");
try { window.dispatchEvent(new CustomEvent("sls-owner-changed", { detail: { custKeys } })); } catch (e) {}
} catch (e) { setErr(coErr(e, "Could not give the customer to " + nameOf(toUser) + ".")); }
setReasonFor(null);
load(tab, tab === "pool" ? q : "");
};
const askReassign = (custKey) => {
const to = pick["re:" + custKey];
if (!to) { setErr("Pick who gets this customer first."); return; }
setErr("");
setReasonFor({ custKeys: [custKey], toUser: to, toName: nameOf(to) });
};
const post = async (path, body, okMsg, fb) => {
setErr(""); setNote("");
try { const r = await coApi(path, { method: "POST", body }); if (okMsg) coToast(okMsg, "ok"); return r || { ok: true }; }
catch (e) { setErr(coErr(e, fb)); return null; }
};
if (me === undefined) return
Loading…
;
if (me === null) {
return (
Customer Ownership
This needs the latest server update. Ask your administrator.
);
}
if (!can) {
return (
Customer Ownership
This screen is for sales managers.
);
}
const d = data[tab] || {};
const reassignControls = (custKey) => (
setPickFor("re:" + custKey, v)} attrs={{ "data-reassign-for": custKey }} />
askReassign(custKey)}>Give customer
);
const body = (() => {
if (tab === "pool") {
const rows = d.customers || [];
return (
<>
setQ(e.target.value)} placeholder="Search name, phone, salesperson…"
style={{ flex: "1 1 220px", minWidth: 0, padding: "6px 8px", fontSize: 13 }} />
{rows.length} customer{rows.length === 1 ? "" : "s"}
These customers bought here, but their salesperson no longer works here (or the deal had no salesperson). When one of them books service,
every salesperson gets an alert and the first to claim gets the customer. You can also give one to someone right here.
{rows.length === 0 && !loading ? No orphan customers. Everyone who bought here has an active salesperson.
: null}
{rows.map((c) => (
{c.name || c.custKey} {c.inService ? In service now : null}
{c.phone ? coPhone(c.phone) + " · " : ""}{(c.vehicles || []).map((v) => v.label).filter(Boolean).join(", ") || "—"}
Sold by {c.soldBy || "no salesperson"}{c.soldOn ? " on " + coDay(c.soldOn) : ""}
{c.lastVisit ? " · last visit " + coDay(c.lastVisit) : ""}
{c.openVisit ? " · booked " + (c.openVisit.type || "service").toLowerCase() + " " + coDay(c.openVisit.date) : ""}
{reassignControls(c.custKey)}
))}
>
);
}
if (tab === "claims") {
const rows = d.claims || [];
return (
<>
A claim lasts 30 days from the last call, text, email or visit the salesperson logs. Then the customer goes back to the pool.
{
const r = await post("/customer-owners/sweep", {}, null, "Could not check the claims. Try again.");
if (r && r.summary) {
const s = r.summary;
setNote("Checked. " + (s.expired ? s.expired + " claim" + (s.expired === 1 ? "" : "s") + " ran out" : "No claim ran out")
+ (s.warned ? ", " + s.warned + " reminder" + (s.warned === 1 ? "" : "s") + " sent" : "") + ".");
}
load("claims");
}}>Check expirations now
{rows.length === 0 && !loading ? No claimed customers right now.
: null}
{rows.map((c) => {
const low = c.daysLeft != null && c.daysLeft <= 5;
return (
{c.name || c.custKey}
Claimed by {c.userName || c.userId} on {coLocalDay(c.claimedAt)}
{c.lastContactAt ? " · last contact " + coLocalDay(c.lastContactAt) : ""}
{c.daysLeft == null ? "" : c.daysLeft + (c.daysLeft === 1 ? " day left" : " days left")}
{
if (!confirm("Send " + (c.name || "this customer") + " back to the orphan pool?")) return;
const r = await post("/customer-owners/release", { custKey: c.custKey, reason: "Released by a manager" }, (c.name || "Customer") + " is back in the orphan pool.", "Could not release this claim.");
if (r) { try { window.dispatchEvent(new CustomEvent("sls-owner-changed", { detail: { custKeys: [c.custKey] } })); } catch (e) {} }
load("claims");
}}>Back to pool
{reassignControls(c.custKey)}
);
})}
>
);
}
if (tab === "assigned") {
const rows = d.rows || [];
return (
<>
Customers a manager gave to someone. A manager's choice wins over who sold the car.
{rows.length === 0 && !loading ? No customers were given to anyone by a manager.
: null}
{rows.map((c) => (
{c.name || c.custKey}
Given to {c.userName || c.userId} by {c.updatedBy || "a manager"}{c.updatedAt ? " on " + coLocalDay(c.updatedAt) : ""}
{
const r = await post("/customer-owners/unassign", { custKey: c.custKey }, (c.name || "Customer") + " now follows who sold the car again.", "Could not undo this.");
if (r) { try { window.dispatchEvent(new CustomEvent("sls-owner-changed", { detail: { custKeys: [c.custKey] } })); } catch (e) {} }
load("assigned");
}}>Undo
{reassignControls(c.custKey)}
))}
>
);
}
if (tab === "aliases") {
const users = (d.users || roster || []).slice();
const unmapped = d.unmapped || [], auto = d.autoMatched || [], mapped = d.aliases || [];
const userLabel = (u) => (u.name || u.id) + (u.active === false ? " (login turned off)" : "");
const aliasSelect = (key, sugg, current) => {
const sIds = (sugg || []).map((s) => s.userId);
const rest = users.filter((u) => sIds.indexOf(u.id) < 0);
return (
setPickFor("al:" + key, e.target.value)} style={{ padding: "4px 6px", fontSize: 12.5, maxWidth: "100%", minWidth: 0 }}>
Pick who this is…
{(sugg || []).map((s) => {(s.name || s.userId) + (s.active === false ? " (login turned off)" : "") + " - suggested"} )}
{rest.map((u) => {userLabel(u)} )}
Left the dealership / never had a login
);
};
const saveAlias = async (nameKey, display, fallback) => {
const v = pick["al:" + nameKey] != null ? pick["al:" + nameKey] : (fallback || "");
if (!v) { setErr("Pick who \"" + display + "\" is first."); return; }
const body = v === "__departed" ? { display, nameKey, userId: null, departed: true } : { display, nameKey, userId: v, departed: false };
const r = await post("/customer-owners/aliases", body,
"\"" + display + "\" " + (v === "__departed" ? "is marked as someone who left. Their customers go to the orphan pool." : "is now " + nameOf(v) + "."),
"Could not save the name match.");
if (r) { setPick((p) => { const n = Object.assign({}, p); delete n["al:" + nameKey]; return n; }); try { window.dispatchEvent(new CustomEvent("sls-owner-changed", { detail: { custKeys: [] } })); } catch (e) {} }
load("aliases");
};
const grp = (t) => {t}
;
return (
<>
Deals imported from the old system carry the salesperson's printed name. Match each name to a K2B login, or mark it as someone who left;
their customers then go to the orphan pool. Until a name is matched, its customers get no 'up for grabs' alerts.
{grp("Waiting for a name match")}
{unmapped.length === 0 && !loading ? Every printed name is matched.
: null}
{unmapped.map((a) => (
“{a.display}” {a.waitingVisits > 0 ? customer in service now : null}
{a.deals} deal{a.deals === 1 ? "" : "s"}{a.lastSold ? " · last sale " + coDay(a.lastSold) : ""}
{aliasSelect(a.nameKey, a.suggestions, "")}
saveAlias(a.nameKey, a.display, "")}>Save
))}
{grp("Matched automatically when imported")}
{auto.length === 0 && !loading ? No names were matched automatically.
: null}
{auto.map((a) => (
“{a.display}” → {a.userName || a.userId}
{a.deals} deal{a.deals === 1 ? "" : "s"}{a.lastSold ? " · last sale " + coDay(a.lastSold) : ""} · change it if the import picked the wrong person
{aliasSelect(a.nameKey, [], a.userId)}
saveAlias(a.nameKey, a.display, a.userId)}>Save
))}
{grp("Matched names")}
{mapped.length === 0 && !loading ? No names matched by a manager yet.
: null}
{mapped.map((a) => (
“{a.display}” → {a.departed ? "left the dealership" : ((a.userName || a.userId) + (a.userActive === false ? " (login turned off)" : ""))}
{a.deals} deal{a.deals === 1 ? "" : "s"} · set by {a.updatedBy || "a manager"}{a.updatedAt ? " on " + coLocalDay(a.updatedAt) : ""}
{aliasSelect(a.nameKey, [], a.departed ? "__departed" : (a.userId || ""))}
saveAlias(a.nameKey, a.display, a.departed ? "__departed" : a.userId)}>Change
{
setErr("");
try { await coApi("/customer-owners/aliases/" + encodeURIComponent(a.nameKey), { method: "DELETE" }); coToast("Match for \"" + a.display + "\" removed.", "ok"); }
catch (e) { setErr(coErr(e, "Could not remove the match.")); }
load("aliases");
}}>Remove
))}
>
);
}
if (tab === "leads") {
const rows = d.leads || [];
const ids = rows.filter((l) => checked[l.leadId]).map((l) => l.leadId);
return (
<>
Open leads that belong to someone whose login is turned off. Nobody in sales can see them until you move them.
0 && ids.length === rows.length}
onChange={(e) => { const n = {}; if (e.target.checked) rows.forEach((l) => { n[l.leadId] = true; }); setChecked(n); }} />
All
setPickFor("leadsTo", v)} attrs={{ id: "co-leads-move-user" }} placeholder="Move to…" />
{
const r = await post("/customer-owners/move-leads", { leadIds: ids, toUser: pick.leadsTo }, null, "Could not move the leads.");
if (r) { coToast((r.moved || 0) + " lead" + (r.moved === 1 ? "" : "s") + " moved to " + nameOf(pick.leadsTo) + ".", "ok"); setChecked({}); }
load("leads");
}}>Move {ids.length ? ids.length + " " : ""}lead{ids.length === 1 ? "" : "s"}
{rows.length === 0 && !loading ? No open leads of people who left.
: null}
{rows.map((l) => (
setChecked((c) => Object.assign({}, c, { [l.leadId]: e.target.checked }))} />
{l.name || l.leadId}
{l.status || "New"} · was {l.ownerName || l.ownerId}'s{l.lastTouch ? " · last touch " + coLocalDay(l.lastTouch) : ""}
))}
>
);
}
return null;
})();
return (
Customer Ownership
Whose customer is whose. Customers of salespeople who left go to the orphan pool; the first salesperson to claim one from a service alert gets them.
{CO_TABS.map((t) => (
{ setErr(""); setNote(""); setTab(t[0]); }}>{t[1]}
))}
{err ?
{err}
: null}
{note ?
{note}
: null}
{body}
{reasonFor ? (
setReasonFor(null)}
onSave={(reason) => doReassign(reasonFor.custKeys, reasonFor.toUser, reason)} />
) : null}
);
};
Object.assign(window, { CustomerOwnership, coIsMgr });