/* eslint-disable */ /* © 2026 K2B NETWORK SOLUTIONS LLC. All rights reserved. Proprietary and confidential. Unauthorized copying, distribution, or use of this file, via any medium, is strictly prohibited. Original work product. */ // JBREWS DMS — Settings module (in-DMS). // Workstation preferences, server user accounts (admin), staff/techs, // integrations, dealer info, GL flow, and data/backup. // Audit C-02c: no credentials or MFA codes are stored on the workstation — // sign-on accounts and passwords live ONLY on the server (Users (Server) tab). const Settings = ({ onClose, go }) => { const [tab, setTab] = React.useState("security"); const [auth, setAuth] = React.useState(() => window.jbLoadAuth()); const [dirty, setDirty] = React.useState(false); if (window.useK2BUnsaved) window.useK2BUnsaved("settings-workstation", { label: "Settings — this workstation", isDirty: () => dirty, save: () => { window.jbSaveAuth(auth); setDirty(false); return true; }, discard: () => { setAuth(window.jbLoadAuth()); setDirty(false); } }); // RC35 (brd) // Exact-role admin check (chrome.jsx → DMS.isAdmin when the core defines it). const isAdmin = window.jbIsAdmin ? window.jbIsAdmin() : /^(admin \/ f&i|admin)$/i.test(String((window.DMS && window.DMS.userRole) ? window.DMS.userRole() : "").trim()); // Messaging (consent registry, history, compose): Admin, managers, Service, F&I — not Sales / Accounting / Parts. const canMsg = isAdmin || !!(window.jbCanMessage && window.jbCanMessage()); const set = (k, v) => { setAuth(a => ({ ...a, [k]: v })); setDirty(true); }; const save = () => { window.jbSaveAuth(auth); setDirty(false); window.k2bToast && window.k2bToast("Workstation settings saved.", "ok", 3000); }; const wipeData = async () => { if (!isAdmin) { window.k2bToast && window.k2bToast("Only an Admin can wipe dealership data.", "warn"); return; } if (!window.confirm("Erase ALL dealership data — deals, ROs, customers, inventory, GL, banks, F&I products, fleet, forms — on THIS workstation AND the server (every station)?\n\nKept: sign-in accounts, dealer settings, integration credentials. This cannot be undone.")) return; const typed = window.prompt("Type WIPE to confirm erasing the shared dealership data:"); if (String(typed || "").trim() !== "WIPE") { window.k2bToast && window.k2bToast("Not wiped — you must type WIPE exactly.", "warn"); return; } try { // 1. Flag a fresh start FIRST — registry stores check this and seed // EMPTY instead of re-seeding their demo defaults. localStorage.setItem("jbrews-fresh-start", "1"); // 2. Blank the dealership — local AND server. serverWipe resolves only // when the server store is empty, so we AWAIT it before reloading // (reloading early re-downloads the old data — the old bug). if (window.DMS && window.DMS.serverWipe) { window.k2bToast && window.k2bToast("Wiping dealership data — local and server…", "warn", 8000); const r = await window.DMS.serverWipe((col, i, n) => { window.k2bToast && window.k2bToast("Wiping server: " + col + " (" + i + "/" + n + ")…", "warn", 1200); }); window.k2bToast && window.k2bToast(r.msg, r.ok ? "ok" : "warn", 6000); if (!r.ok) { if (r.refused) { try { localStorage.removeItem("jbrews-fresh-start"); } catch (e) {} } return; } // leave keys intact so a re-run can finish the job; RC36 (sec36, S4): a REFUSED wipe (owner only) changes nothing here either } else if (window.DMS && window.DMS.startBlank) window.DMS.startBlank(); // 3. Data-bearing registry stores → EMPTY LIST (never remove the key — // a missing key is what triggers demo defaults on stores that // predate the fresh-start flag). // RC35 (brd): the registries of THIS store (their keys carry "@" - StoreBoot.js.txt); another store's are never touched Object.keys(localStorage).forEach(k => { const m = /^(jbrews-(lenders|products|forms|fleet|fcso)-v\d+)(?:@(\d+))?$/.exec(k); if (m && (!m[3] || !window.K2BStore || m[3] === window.K2BStore.pageId())) localStorage.setItem(m[1], "[]"); }); // 4. Clear every other module key (desk scenarios, CRM notes, imports, // saved form data, JE drafts…). Keep ONLY: blank core, sign-on // config, user accounts, per-module settings, integration creds. const keep = [ "jbrews-dms-core", "jbrews-users-", "jbrews-auth-config", "jbrews-auth-token", "jbrews-modsettings", "jbrews-routeone-cfg", "jbrews-carfax", "jbrews-vauto", "jbrews-msg-cfg", "jbrews-fresh-start", "jbrews-lenders-", "jbrews-products-", "jbrews-forms-", "jbrews-fleet-", "jbrews-fcso-", "jbrews-vault-", // (blocker 4) encryption at rest stays ON — only "Turn Off Encryption" (audited) may downgrade ]; const here = window.K2BStore && window.K2BStore.pageId(); Object.keys(localStorage).forEach(k => { if (!/^(jbrews-|k2b|jb_)/i.test(k)) return; if (keep.some(p => k.startsWith(p))) return; if (/^k2b-(brand-|last-store|primary-store|store-)/.test(k)) return; // RC35: which store / its branding - per PC const at = k.lastIndexOf("@"); if (at > 0 && /^\d+$/.test(k.slice(at + 1)) && k.slice(at + 1) !== here) return; // another store's data if (/^jbrews-dms-core-v3-s\d+/.test(k) && !(here && k.indexOf("jbrews-dms-core-v3-s" + here) === 0)) return; try { localStorage.removeItem(k); } catch (e) {} // If removal is blocked (some environments guard it), blank the value // instead so nothing can be read back out of it. if (localStorage.getItem(k) != null) { try { localStorage.setItem(k, ""); } catch (e) {} } }); } catch (e) {} window.k2bToast && window.k2bToast("Workstation wiped — dealership data cleared to blank. Reloading…", "warn", 3000); setTimeout(() => location.reload(), 900); }; return (
{/* Tabs */}
{[ { k: "security", label: "Workstation & Sign-On", icon: "🔐" }, { k: "team", label: "Users (Server)", icon: "🖥", adminOnly: true }, { k: "secpanel", label: "Security", icon: "🛡", adminOnly: true }, { k: "staff", label: "Staff & Techs", icon: "🔧" }, { k: "integrations", label: "Integrations (API)", icon: "🔗", adminOnly: true }, { k: "aiapps", label: "AI Apps (Claude)", icon: "✦", needs: "AiAppsPanel" }, { k: "messaging", label: "Messaging & Consent", icon: "💬", msgOnly: true }, { k: "apps", label: "Vendor Sites", icon: "🌐" }, { k: "vauto", label: "vAuto & Feeds", icon: "🚗" }, { k: "servicesetup", label: "Service Setup", icon: "🛠", needs: "ServiceSetupPanel" }, { k: "dealer", label: "Dealer Info", icon: "🏬" }, { k: "glflow", label: "GL Flow", icon: "🧾" }, { k: "data", label: "Data & Backup", icon: "💾" }, { k: "about", label: "About", icon: "ℹ" }, ].filter(t => (!t.adminOnly || isAdmin) && (!t.msgOnly || canMsg) && (!t.needs || window[t.needs])).map(t => ( ))}
{/* Pane */}
{tab === "security" && (
The store you are in (the server decides it at sign-in). set("station", v.slice(0, 32))} width={220} /> Optional label (e.g. FRONT-DESK-1) for your own reference.
Accounts are validated by the server — nothing on this PC can sign anyone in. {isAdmin ? <> Add employees, change roles, reset passwords and manage MFA in the Users (Server) tab. : <> To change your password or MFA, ask an admin (Settings → Users (Server)).}
{dirty && Unsaved changes.}
)} {tab === "dealer" && (() => { // RC35 (brd): the ACTIVE store's branding, read only - the owner changes it in Owner Panel > Stores const B = window.K2BStore ? window.K2BStore.branding() : { gm: {} }; const gm = window.K2BStore && window.K2BStore.franchise(); return (
{gm ? : null} {gm ? : null}

The owner changes this in Owner Panel > Stores — these values appear on every legal form.

); })()} {tab === "data" && (
  • Every live DMS collection (customers, deals, inventory, ROs, parts, GL & journal, A/R, CRM leads…)
  • This workstation's registries: Banks / Lenders (incl. GMF titling entities), F&I Products, Forms Sequencing, Fleet
  • Saved form drafts on this PC
  • Never included: passwords, MFA, integration keys
{isAdmin && ( )}
{!isAdmin &&
Wiping dealership data is an Admin action.
}
)} {tab === "team" && isAdmin && } {tab === "secpanel" && isAdmin && (window.SecurityPanel ? :
Security module not loaded (Security.jsx.txt).
)} {tab === "staff" && } {tab === "integrations" && isAdmin && (window.IntegrationsAdminPanel ? setTab("messaging")} /> :
Integrations.jsx.txt is not loaded.
)} {tab === "aiapps" && window.AiAppsPanel && } {tab === "messaging" && canMsg && (window.MessagingCenter ? :
MessagingCenter.jsx.txt is not loaded.
)} {tab === "apps" && (
{window.IntegrationStatusStrip &&
setTab("integrations")) : null} admin={false} />
} {window.ConnectedAppsPanel ? :
ConnectedApps.jsx.txt is not loaded.
}
)} {tab === "servicesetup" && (window.ServiceSetupPanel ? : null)} {tab === "vauto" && } {tab === "glflow" && } {tab === "about" && (
Version 1.0.0
Build {new Date().toISOString().slice(0, 10)}
Store: {window.K2BStore ? (window.K2BStore.pageId() || "") + " · " + window.K2BStore.branding().dba : ""}
This build includes Desking, F&I, Inventory, Service, Parts, Accounting, Payroll and the full Forms library. All data persists locally per workstation; multi-station sync is available with an upgraded license.
)}
); }; const SecHead = ({ title, sub }) => (
{title}
{sub}
); const Group = ({ label, children }) => (
{label}
{children}
); const Row = ({ label, children }) => (
{label}
{children}
); const SetInput = ({ value, onChange, width = 200, type = "text", readOnly, disabled }) => { const [show, setShow] = React.useState(false); const realType = type === "password" && !show ? "password" : "text"; return (
onChange(e.target.value) : undefined} readOnly={readOnly} disabled={disabled} style={{ width, padding: "6px 10px", boxSizing: "border-box", border: "1px solid " + (disabled ? "#E0E0E0" : "#C8CCD2"), background: readOnly ? "#F4F4F4" : "#FFF", color: disabled ? "#999" : "#0b1424", fontFamily: "var(--font-ui)", fontSize: 13, outline: 0, paddingRight: type === "password" ? 56 : 10, }} /> {type === "password" && !readOnly && ( )}
); }; const Toggle = ({ value, onChange }) => ( ); const selectStyle = { padding: "6px 10px", border: "1px solid #C8CCD2", background: "#FFF", fontFamily: "var(--font-ui)", fontSize: 13, color: "#0b1424", }; Object.assign(window, { Settings }); /* ─── Registry exchange (audit C-04) ──────────────────────────────────── The lender/ACAR book, F&I product prices, forms sequencing and fleet accounts are still localStorage-only (window.jbLenders / jbProducts / jbForms / jbFleet). Until DMSCore gains shared `lenders` / `products` / `forms` / `fleet` collections, this lets an admin export the book from one station and merge it into the others so every desk prices the same. */ const JB_REGISTRIES = [ ["lenders", "Banks / Lenders (incl. GMF titling entities)", "jbLenders"], ["products", "F&I Products & prices", "jbProducts"], ["forms", "Forms Sequencing", "jbForms"], ["fleet", "GM Fleet Accounts", "jbFleet"], ]; const jbCollectRegistries = () => { const out = {}; JB_REGISTRIES.forEach(([key, , w]) => { const s = window[w]; if (s && s.list) { try { out[key] = s.list(); } catch (e) {} } }); return out; }; const RegistryExchangeGroup = () => { const [, force] = React.useReducer(n => n + 1, 0); React.useEffect(() => { const offs = JB_REGISTRIES.map(([, , w]) => (window[w] && window[w].subscribe) ? window[w].subscribe(force) : null); return () => offs.forEach(off => off && off()); }, []); const [msg, setMsg] = React.useState(""); const counts = JB_REGISTRIES.map(([key, label, w]) => [label, (window[w] && window[w].list) ? window[w].list().length : 0]); const exportBook = () => { const payload = { format: "JBREWS-REGISTRIES", version: 1, exported: new Date().toISOString(), registries: jbCollectRegistries() }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "jbrews-lender-book-" + new Date().toISOString().slice(0, 10) + ".json"; a.click(); URL.revokeObjectURL(url); setMsg("Lender / product / forms book exported. Import it on each other workstation."); }; const importBook = (file) => { if (!file) return; const rd = new FileReader(); rd.onload = () => { let p = null; try { p = JSON.parse(String(rd.result || "")); } catch (e) { setMsg("That file is not valid JSON."); return; } const reg = (p && p.registries) || (p && p.core && p.registries) || null; if (!reg || typeof reg !== "object") { setMsg("No registry data found in that file (export one from Settings → Data & Backup first)."); return; } const done = []; JB_REGISTRIES.forEach(([key, label, w]) => { const store = window[w]; const rows = reg[key]; if (!store || !store.upsert || !Array.isArray(rows)) return; let n = 0; rows.forEach(r => { if (r && r.id) { try { store.upsert(r); n++; } catch (e) {} } }); done.push(`${n} ${label.split(" (")[0].toLowerCase()}`); }); setMsg(done.length ? "Merged: " + done.join(", ") + ". Existing records with the same id were updated." : "Nothing to import."); window.k2bToast && window.k2bToast("Registry book imported.", "ok"); }; rd.readAsText(file); }; return (
These books are stored on this PC only. After editing Banks / Lenders, F&I Products or Forms Sequencing, export the book here and import it on every other workstation so all desks quote the same rates, prices and packets.
On this PC: {counts.map(([l, n]) => `${n} ${l.split(" (")[0].toLowerCase()}`).join(" · ")}
{msg &&
{msg}
}
); }; Object.assign(window, { RegistryExchangeGroup, jbCollectRegistries }); /* ─── (Removed — audit C-02c) ──────────────────────────────────────────── The parallel local "Users & Logins" panel (UsersPanel / UserEditor) that kept plaintext passwords in window.jbUsers has been deleted. The server Users tab (TeamUsersPanel below) is the ONLY place accounts are managed. */ /* ─── Server Sync (Data & Backup) — visible state + force-push recovery ── The recovery path for workstations that worked offline: pushes EVERY record saved on this computer to the shared server via DMS.pushAll(). */ const ServerSyncGroup = () => { const D = window.DMS; const readCs = () => (D && D.connState) ? D.connState() : "offline"; const [cs, setCs] = React.useState(readCs); const [modal, setModal] = React.useState(null); // {busy:true} | {result:{ok,pushed,errors}} // Per-record permanent sync failures (poison records) — DMS.syncIssues(). const readIssues = () => (D && D.syncIssues) ? D.syncIssues() : []; const [issues, setIssues] = React.useState(readIssues); const [showIssues, setShowIssues] = React.useState(false); React.useEffect(() => { const refresh = () => { setCs(readCs()); setIssues(readIssues()); }; window.addEventListener("k2b-conn", refresh); const off = (D && D.subscribe) ? D.subscribe(refresh) : null; const t = setInterval(refresh, 5000); return () => { window.removeEventListener("k2b-conn", refresh); if (off) off(); clearInterval(t); }; }, []); const ti = (D && D.tokenInfo) ? D.tokenInfo() : null; const LABELS = { online: ["#16A34A", "ONLINE — shared data"], offline: ["#A02234", "WORKING LOCALLY — changes NOT shared"], "auth-expired":["#B26A00", "SESSION EXPIRED — sign off and sign back in"], connecting: ["#666", "CONNECTING…"], }; const [dotColor, stateText] = LABELS[cs] || LABELS.offline; const runSync = async () => { if (!(D && D.pushAll)) { setModal({ result: { ok: false, pushed: 0, errors: ["DMS core not loaded — cannot sync."] } }); return; } setModal({ busy: true }); let r; try { r = await D.pushAll(); } catch (e) { r = { ok: false, pushed: 0, errors: [e.message || "Sync failed"] }; } setModal({ result: r || { ok: false, pushed: 0, errors: ["No result returned"] } }); }; return (
● {stateText}
{ti ? <>Signed in as {ti.name || ti.sub}{ti.role ? <> ({ti.role}) : null} — session {ti.expired ? EXPIRED : <>expires {ti.exp ? new Date(ti.exp * 1000).toLocaleString() : "n/a"}} : "Not signed in to the server."}
Use after working offline — pushes everything saved on this computer to the shared server.
{issues.length > 0 && (
⚠ {issues.length} record{issues.length === 1 ? "" : "s"} could not be saved to the server
{showIssues && (
    {issues.map((x, i) => (
  • {x.collection}/{String(x.id)} — {String(x.error || ("HTTP " + x.status))} {x.at ? · {new Date(x.at).toLocaleString()} : null}
  • ))}
)}
The server rejected these records permanently — they were set aside so the rest of your work keeps syncing.
)} {modal && modal.busy && ( {}}>
⇧ Pushing every record on this workstation to the server… keep this window open.
)} {modal && modal.result && ( setModal(null)} buttons={ setModal(null)}>Close}>
{modal.result.ok ? "✔ Sync complete" : "⚠ Sync finished with problems"}
Pushed {modal.result.pushed} records; {(modal.result.errors || []).length} errors.
{(modal.result.errors || []).length > 0 && (
    {(modal.result.errors || []).slice(0, 12).map((e, i) =>
  • {String(e)}
  • )} {(modal.result.errors || []).length > 12 &&
  • …and {(modal.result.errors || []).length - 12} more
  • }
)}
)}
); }; /* ─── Users (Server) — admin-only server sign-in accounts (/api/team) ── */ const TEAM_ROLES = ["Admin / F&I", "Office Manager", "Sales Manager", "Service Advisor", "Sales", "Accounting", "Service", "Parts"]; const TEAM_ADMIN_ROLE = "Admin / F&I"; // Temp-password policy (audit C-02b): the server enforces ≥ 10 characters (RC36); // the client mirrors it and adds a readable hint. const TEAM_PW_MIN = 10; // RC36 (sec36, S3): the server rule is 10 characters, not the login id, not the name const teamPwHint = `at least ${TEAM_PW_MIN} characters — a short phrase works well (e.g. blue-truck-2026)`; const teamPwCheck = (pw) => (String(pw || "").length < TEAM_PW_MIN ? `Password must be ${teamPwHint}.` : ""); // Full policy (Security.jsx.txt → window.jbPasswordPolicy: ≥10 chars, upper + // lower + digit, not the user name, not a common password). Falls back to the // server minimum when the security module is not loaded. const teamPwPolicyErr = (pw, id, confirm) => { if (window.jbPasswordPolicy) { const r = window.jbPasswordPolicy(pw, id); if (!r.ok) return "Password needs: " + r.problems.join("; ") + "."; } else { const e = teamPwCheck(pw); if (e) return e; } if (confirm !== undefined && String(confirm) !== String(pw)) return "The two passwords do not match — re-type the new password."; return ""; }; const teamPwRules = "at least 10 characters with an uppercase letter, a lowercase letter and a digit — not the employee # and not a common word"; const teamBlank = { id: "", name: "", role: "Sales", password: "", confirm: "" }; const TeamUsersPanel = () => { const D = window.DMS; const [users, setUsers] = React.useState(null); // null = loading const [offline, setOffline] = React.useState(""); const [form, setForm] = React.useState({ ...teamBlank }); const [formErr, setFormErr] = React.useState(""); const [busy, setBusy] = React.useState(false); const [resetFor, setResetFor] = React.useState(null); // user record being reset const setF = (k, v) => { setForm(f => ({ ...f, [k]: v })); setFormErr(""); }; const refresh = async () => { if (!D || !D.teamList) { setOffline("DMS core not loaded."); setUsers([]); return; } const r = await D.teamList(); if (r && r.ok) { setUsers(r.users || []); setOffline(""); } else { setUsers([]); setOffline((r && r.error) || "No response from server."); } }; React.useEffect(() => { refresh(); }, []); const create = async () => { const id = (form.id || "").trim(); if (!/^[A-Za-z0-9]{1,10}$/.test(id)) { setFormErr("Employee # must be 1-10 letters/digits."); return; } const pwErr = teamPwPolicyErr(form.password, id, form.confirm); if (pwErr) { setFormErr("Temporary " + pwErr.charAt(0).toLowerCase() + pwErr.slice(1)); return; } setBusy(true); const r = await D.teamCreate({ id, name: (form.name || "").trim(), role: form.role, password: form.password }); setBusy(false); if (r && r.ok) { setForm({ ...teamBlank }); try { D.logAudit && D.logAudit("Password Set", "#" + id + " · new employee (" + form.role + ")"); } catch (e) {} window.k2bToast && window.k2bToast(`Employee #${id} created.`, "ok"); refresh(); } else setFormErr((r && r.error) || "Create failed."); }; const pwPolicyOk = !form.password || !teamPwPolicyErr(form.password, (form.id || "").trim(), form.confirm); const me = (D && D.userId) ? D.userId() : ""; const isAdminRole = (r) => String(r || "").trim().toLowerCase() === TEAM_ADMIN_ROLE.toLowerCase(); const activeAdmins = (users || []).filter(u => u.active && isAdminRole(u.role)); // Client-side mirror of the server's self / last-admin guards (audit C-02b) // so the user gets a friendly message instead of a bare 4xx. const guardChange = (u, next) => { const isMe = String(u.id) === String(me); const wasAdmin = u.active && isAdminRole(u.role); const staysAdmin = (next.active !== undefined ? next.active : u.active) && isAdminRole(next.role !== undefined ? next.role : u.role); if (isMe && next.active === false) return "You can't deactivate your own account — ask another admin."; if (isMe && wasAdmin && !staysAdmin) return "You can't remove your own Admin role — ask another admin."; if (wasAdmin && !staysAdmin && activeAdmins.length <= 1) return "That is the last active Admin — promote someone else first."; return ""; }; const toggleActive = async (u) => { const g = guardChange(u, { active: !u.active }); if (g) { window.k2bToast && window.k2bToast(g, "warn", 4200); return; } if (u.active && !window.confirm(`Deactivate ${u.name || "employee"} #${u.id}? They will no longer be able to sign in.`)) return; const r = await D.teamUpdate({ id: u.id, active: !u.active }); if (r && r.ok) window.k2bToast && window.k2bToast(`Employee #${u.id} ${u.active ? "deactivated" : "reactivated"}.`, u.active ? "warn" : "ok"); else window.k2bToast && window.k2bToast((r && r.error) || "Update failed.", "warn", 4200); refresh(); }; // Role edit after create (audit C-02b) → DMS.teamUpdate({ id, role }). const changeRole = async (u, role) => { if (role === u.role) return; const g = guardChange(u, { role }); if (g) { window.k2bToast && window.k2bToast(g, "warn", 4200); refresh(); return; } if (!window.confirm(`Change ${u.name || "employee"} #${u.id} from "${u.role}" to "${role}"? Takes effect at their next sign-in.`)) { refresh(); return; } const r = (D && D.teamUpdate) ? await D.teamUpdate({ id: u.id, role }) : { ok: false, error: "DMS core not loaded." }; if (r && r.ok) window.k2bToast && window.k2bToast(`Employee #${u.id} is now ${role}.`, "ok"); else window.k2bToast && window.k2bToast((r && r.error) || "Role update failed.", "warn", 4200); refresh(); }; const cellStyle = { padding: "8px 12px" }; const inp = { padding: "6px 9px", border: "1px solid #C8CCD2", background: "#FFF", fontFamily: "var(--font-ui)", fontSize: 13, boxSizing: "border-box" }; return (
{offline && (
Server unavailable — user management requires the server connection. ({offline})
)} {users === null &&
Loading users…
} {!offline && users !== null && (
{["Employee #", "Name", "Role", "Status", ""].map((h, i) => )} {users.map((u) => ( ))} {!users.length && }
{h}
{u.id}{u.id === me && (you)} {u.name} {u.active ? "Active" : "Inactive"} {" "}
No users on the server yet.
)} {!offline && users !== null && (
{form.password && (
{window.JbPasswordMeter ? : (teamPwCheck(form.password) && {teamPwCheck(form.password)})}
)} {formErr &&
{formErr}
}

The new employee signs in with their employee # and the temporary password ({teamPwRules}); an admin can reset it here any time. Roles can be changed in the table above. The system always keeps at least one active admin — the last one cannot be deactivated or demoted, and you cannot deactivate or demote yourself.

)} {resetFor && setResetFor(null)} onDone={() => { setResetFor(null); refresh(); }} />}
); }; const TeamResetPassword = ({ user, onClose, onDone }) => { const [pw, setPw] = React.useState(""); const [confirm, setConfirm] = React.useState(""); const [err, setErr] = React.useState(""); const [busy, setBusy] = React.useState(false); const policyOk = !!pw && !teamPwPolicyErr(pw, user.id, confirm); const apply = async () => { const pwErr = teamPwPolicyErr(pw, user.id, confirm); if (pwErr) { setErr(pwErr); return; } setBusy(true); const r = await window.DMS.teamSetPassword(user.id, pw); setBusy(false); if (r && r.ok) { try { window.DMS.logAudit && window.DMS.logAudit("Password Reset", "#" + user.id + " · by admin"); } catch (e) {} window.k2bToast && window.k2bToast(`Password reset for #${user.id}.`, "ok"); onDone(); } else setErr((r && r.error) || "Reset failed."); }; return ( Cancel setErr(teamPwPolicyErr(pw, user.id, confirm) || "Enter the new password twice.")} style={{ opacity: policyOk ? 1 : 0.6 }}>{busy ? "Saving…" : "Set Password"} }>
New password for {user.name || "employee"} (#{user.id}):
{ setPw(v); setErr(""); }} width={260} type="password" />
Re-type the new password:
{ setConfirm(v); setErr(""); }} width={260} type="password" /> {window.JbPasswordMeter && }
Must be {teamPwRules}. The employee should change it after signing in.
{err &&
{err}
}
); }; Object.assign(window, { TeamUsersPanel }); /* ─── Staff & Techs — payroll employees + service technicians ──────── */ // Audit A-01: the labelled-field wrapper lives at MODULE scope. Declared // inside StaffPanel it was a brand-new component type on every keystroke, so // React unmounted/remounted every input and the autoFocus Name box stole the // caret after one character. const staffInp = { fontSize: 13, padding: "5px 7px", border: "1px solid #C8CCD2", borderRadius: 4 }; const StaffField = ({ label, children }) => ( ); const StaffPanel = () => { const [, force] = React.useReducer(n => n + 1, 0); React.useEffect(() => (window.DMS && DMS.subscribe) ? DMS.subscribe(force) : undefined, []); const act = (r) => { window.k2bToast && window.k2bToast(r.msg, r.ok ? "ok" : "warn"); force(); return r; }; const emps = (window.DMS && DMS.employees) ? DMS.employees() : []; const techs = (window.DMS && DMS.technicians) ? DMS.technicians() : []; const [empEdit, setEmpEdit] = React.useState(null); const [techEdit, setTechEdit] = React.useState(null); const inp = staffInp; const F = StaffField; // stable module-scope component — never remounts inputs // autoFocus applies only on the editor's FIRST mount (the modal opening) — // the input is keyed by the record so a new record gets one initial focus. return (
{/* ── Employees ── */}
Payroll Employees ({emps.length})
{["ID", "Name", "Dept", "Type", "Rate", ""].map((h, i) => )} {emps.map((e, i) => ( setEmpEdit({ ...e })}> ))} {!emps.length && }
{h}
{e.id} {e.name} {e.dept} {e.type} {(+e.rate || 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}{e.type === "Salary" ? "/mo" : "/hr"} edit ›
No employees yet — add your staff here; they appear in Payroll.
{/* ── Technicians ── */}
Service Technicians ({techs.length})
{["ID", "Name", "Skill", "Rate/hr", ""].map((h, i) => )} {techs.map((t, i) => ( setTechEdit({ ...t })}> ))} {!techs.length && }
{h}
{t.id} {t.name} {t.skill} {(+t.rate || 0).toFixed(2)} edit ›
No technicians yet — add techs here; they appear in RO dispatch and the scheduler.
{/* ── Employee editor ── */} {empEdit && ( setEmpEdit(null)} buttons={<> {empEdit.id && emps.some(x => x.id === empEdit.id) && { if (window.confirm("Remove " + empEdit.name + "?")) { act(DMS.removeEmployee(empEdit.id)); setEmpEdit(null); } }}>Delete} setEmpEdit(null)}>Cancel { const r = act(DMS.upsertEmployee(empEdit)); if (r.ok) setEmpEdit(null); }}>Save }>
setEmpEdit(p => ({ ...p, name: e.target.value.toUpperCase() }))} style={{ ...inp, textTransform: "uppercase" }} autoFocus /> setEmpEdit(p => ({ ...p, id: e.target.value.toUpperCase() }))} style={inp} /> setEmpEdit(p => ({ ...p, rate: e.target.value.replace(/[^0-9.]/g, "") }))} style={{ ...inp, fontFamily: "monospace" }} />
Deductions (child support, garnishments, insurance) are edited per-employee in Payroll → Edit.
)} {/* ── Technician editor ── */} {techEdit && ( setTechEdit(null)} buttons={<> {techEdit.id && techs.some(x => x.id === techEdit.id) && { if (window.confirm("Remove " + techEdit.name + "?")) { act(DMS.removeTechnician(techEdit.id)); setTechEdit(null); } }}>Delete} setTechEdit(null)}>Cancel { const r = act(DMS.upsertTechnician(techEdit)); if (r.ok) setTechEdit(null); }}>Save }>
setTechEdit(p => ({ ...p, name: e.target.value }))} style={inp} autoFocus /> setTechEdit(p => ({ ...p, id: e.target.value.toUpperCase() }))} style={inp} /> setTechEdit(p => ({ ...p, rate: e.target.value.replace(/[^0-9.]/g, "") }))} style={{ ...inp, fontFamily: "monospace" }} />
Techs show up in RO dispatch, the scheduler, and tech-productivity reporting.
)}
); }; Object.assign(window, { StaffPanel }); /* ─── (removed) Carfax / RouteOne browser-stored credential panels. They kept API keys/passwords in this browser and showed "Connected" without any test. Credentials now live encrypted on the server: Settings → Integrations (API). */ /* ─── vAuto & marketplace feeds ───────────────────────────────── */ // The old vAuto card kept the vAuto username / API key in this browser — purge it. try { localStorage.removeItem("jbrews-vauto-cfg"); } catch (e) {} const vaInp = { width: "100%", boxSizing: "border-box", padding: "7px 9px", border: "1px solid #C8CCD2", borderRadius: 4, fontSize: 13 }; const VAutoPanel = () => { const [result, setResult] = React.useState(""); const exportCsv = () => { const units = (window.DMS && window.DMS.inventory && window.DMS.inventory()) || []; if (!units.length) { setResult("No inventory to export — stock units in first."); return; } const cols = ["Stock #", "VIN", "Year", "Make", "Model", "Body", "Color", "Odometer", "New/Used", "Price", "Cost", "Days In Stock", "Certified"]; const esc = (v) => { const s = String(v == null ? "" : v); return /[",\n]/.test(s) ? '"' + s.split('"').join('""') + '"' : s; }; const lines = [cols.join(",")].concat(units.map(u => [u.stock, u.vin, u.yr, u.mk, u.mdl, u.body || "", u.color || "", u.miles || 0, /^n/i.test(u.type || "") ? "New" : "Used", (+u.asking || +u.list || 0), (+u.cost || 0), u.age || 0, u.certified ? "Y" : "N"].map(esc).join(","))); const blob = new Blob([lines.join("\r\n")], { type: "text/csv" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "vauto-inventory-" + new Date().toISOString().slice(0, 10) + ".csv"; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); setResult(units.length + " unit(s) exported — upload the file in vAuto (or any marketplace bulk-upload tool)."); }; const importPricing = (file) => { if (!file) return; const rd = new FileReader(); rd.onload = () => { const rows = String(rd.result || "").split(/\r?\n/).map(l => l.split(",").map(c => c.replace(/^"|"$/g, "").trim())).filter(r => r.length >= 2 && r[0]); if (!rows.length) { setResult("Could not read any rows from " + file.name + "."); return; } let hdr = 0, stockI = 0, priceI = 1; const h = rows[0].map(c => c.toLowerCase()); if (h.some(c => /stock|vin/.test(c))) { hdr = 1; stockI = Math.max(0, h.findIndex(c => /stock|vin/.test(c))); const pi = h.findIndex(c => /price|ask|internet/.test(c)); if (pi >= 0) priceI = pi; } let n = 0, miss = 0; rows.slice(hdr).forEach(r => { const price = parseFloat(String(r[priceI]).replace(/[^0-9.]/g, "")); if (!r[stockI] || !price) return; const res = window.DMS.updateUnit(r[stockI], { asking: price }); res.ok ? n++ : miss++; }); setResult(n + " unit price(s) updated from " + file.name + (miss ? " · " + miss + " not matched by stock/VIN" : "") + "."); window.k2bToast && window.k2bToast(n + " unit prices updated.", n ? "ok" : "warn"); }; rd.readAsText(file); }; return (
vAuto API credentials are no longer stored on this workstation. An administrator enters them in Settings → Integrations (API) → vAuto (stored encrypted on the server; the API feed goes live only after Cox Automotive partner approval and a successful test). The CSV export/import below works today.
Send inventory OUT
CSV with stock #, VIN, year/make/model, mileage, price — accepted by vAuto, CarGurus, AutoTrader, Cars.com bulk tools.
Bring pricing IN
Drop a vAuto pricing export (columns: stock or VIN + price) — asking prices update across the whole DMS instantly.
{result &&
{result}
}
); }; Object.assign(window, { VAutoPanel }); /* ─── GL Flow editor — remap which account each posting flow hits ─────── */ const GLFlowEditor = ({ go }) => { const [, force] = React.useReducer(n => n + 1, 0); React.useEffect(() => (window.DMS && window.DMS.subscribe) ? window.DMS.subscribe(force) : undefined, []); const rows = (window.DMS && window.DMS.getAcctMap) ? window.DMS.getAcctMap() : []; const [draft, setDraft] = React.useState({}); const [coaOpen, setCoaOpen] = React.useState(false); const valOf = (r) => (r.key in draft ? draft[r.key] : r.acct); const dirty = Object.keys(draft).some(k => { const r = rows.find(x => x.key === k); return r && draft[k] !== r.acct; }); const coa = (typeof window !== "undefined" && window.GM_COA) || []; const coaName = (acct) => { const hit = coa.find(e => String(Array.isArray(e) ? e[0] : e.acct) === String(acct)); return hit ? (Array.isArray(hit) ? hit[1] : hit.name) : ""; }; const save = () => { const r = window.DMS.setAcctMap(draft); setDraft({}); window.k2bToast && window.k2bToast(r.msg, r.ok ? "ok" : "warn", 3600); }; const reset = () => { if (!window.confirm("Reset every GL account mapping back to the standard GM chart?")) return; const r = window.DMS.resetAcctMap(); setDraft({}); window.k2bToast && window.k2bToast(r.msg, "ok", 3200); }; const groups = {}; rows.forEach(r => { (groups[r.group] = groups[r.group] || []).push(r); }); return (
{go && } {dirty && ● unsaved changes}
{coaOpen && (
{coa.map((e, i) => { const acct = Array.isArray(e) ? e[0] : e.acct; const name = Array.isArray(e) ? e[1] : e.name; const type = Array.isArray(e) ? e[2] : e.type; return ( ); })}
{acct} {name} {type}
)} {Object.keys(groups).map(g => ( {groups[g].map(r => { const v = valOf(r); return (
{r.label} {r.changed && remapped from {r.def}}
setDraft(d => ({ ...d, [r.key]: e.target.value }))} style={{ width: 88, padding: "5px 8px", border: "1px solid #C8CCD2", fontFamily: "var(--jb-mono, monospace)", fontSize: 13, fontWeight: 700, color: "var(--jb-navy)", textAlign: "center", background: (r.key in draft && draft[r.key] !== r.acct) ? "#FFFBEA" : "#FFF" }} />
{coaName(v) || "—"}
); })}
))}
); }; Object.assign(window, { GLFlowEditor });