/* © 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 */ // K2B DMS — Settings › AI Apps (Claude connector). // • How to connect (the connector address) and this user's permission. // • Admin: AI Connector permission per user — None / Read / Update. // • Connected apps — Disconnect (own; admin sees everyone's). // • Changes made through Claude — Undo (own; admin/office see all). // • Drafts Claude saved (commission plans, budgets, journal entries) — a // person applies or rejects them here; nothing is ever activated by Claude. // All data comes from the server (/api/ai/...). Nothing is kept in the browser. const aiApi = async (path, method, body) => { try { const d = await apiFetch("/ai" + path, { method: method || "GET", body }); return { ok: true, data: d || {} }; } catch (e) { return { ok: false, status: e.status || 0, data: e.body || {}, error: (e.body && e.body.error) || e.message || "Server unreachable" }; } }; const aiFmt = (d) => d ? new Date(d).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : "—"; const aiMoney = (n) => "$" + (+n || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const AI_ACCESS_LABEL = { none: "None", read: "Read", update: "Update" }; const aiCard = { background: "#FFF", border: "1px solid #E2E5EA", borderRadius: 8, padding: "12px 14px" }; const aiTh = { textAlign: "left", fontSize: 11, color: "#666", fontWeight: 700, padding: "6px 8px", borderBottom: "1px solid #E2E5EA", whiteSpace: "nowrap" }; const aiTd = { fontSize: 12.5, padding: "6px 8px", borderBottom: "1px solid #F0F1F4", verticalAlign: "top" }; const aiToast = (m, t) => window.k2bToast && window.k2bToast(m, t || "ok"); const AiSection = ({ title, sub, right, children }) => (
{title}
{right}
{sub &&
{sub}
} {children}
); const AiDraftView = ({ d }) => { const c = d.content || {}; if (d.kind === "commission-plan") { const p = c.plan || {}; return (
New {Math.round((+p.newPct || 0) * 100)}% · Used {Math.round((+p.usedPct || 0) * 100)}% of front gross · Mini {aiMoney(p.miniFloor)} · VSC {aiMoney(p.vscBonus)} · GAP {aiMoney(p.gapBonus)} · Appearance {aiMoney(p.appearanceBonus)} · {p.fundedOnly ? " paid when funded" : " paid at delivery"} · pack {p.packCounts ? "counts" : "ignored"} {Array.isArray(p.volumeBonus) && p.volumeBonus.length > 0 && <> · volume bonus {p.volumeBonus.map(t => `${t.units}+ units ${aiMoney(t.bonus)}`).join(", ")}}
); } if (d.kind === "journal-entry") { return ( {(c.lines || []).map((l, i) => ( ))}
AcctNameDebitCredit
{l.acct}{l.name || l.desc || ""} {l.dr ? aiMoney(l.dr) : ""}{l.cr ? aiMoney(l.cr) : ""}
); } if (d.kind === "budget") { const lines = c.lines || []; const total = lines.reduce((s, l) => s + (+l.total || 0), 0); return (
{c.year} · {lines.length} account lines · total {aiMoney(total)} {(c.unknownAccounts || []).length > 0 && · not in chart of accounts: {c.unknownAccounts.join(", ")}}
); } return null; }; const aiBudgetCsv = (d) => { const c = d.content || {}; const mon = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const q = (v) => /[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g, '""')}"` : String(v); const rows = [["Account", "Name", ...mon, "Total", "Basis"], ...(c.lines || []).map(l => [l.acct, l.name || "", ...(l.months || []), l.total, l.basis || ""])]; const blob = new Blob([rows.map(r => r.map(q).join(",")).join("\n") + "\n"], { type: "text/csv" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `budget-${c.year || "draft"}-${d.id}.csv`; a.click(); URL.revokeObjectURL(url); }; const AiAppsPanel = ({ go }) => { const D = window.DMS; const [status, setStatus] = React.useState(null); const [users, setUsers] = React.useState(null); const [conns, setConns] = React.useState([]); const [changes, setChanges] = React.useState([]); const [drafts, setDrafts] = React.useState([]); const [err, setErr] = React.useState(""); const [busy, setBusy] = React.useState(""); const [open, setOpen] = React.useState(null); const load = React.useCallback(async () => { const s = await aiApi("/status"); if (!s.ok) { setErr(s.status === 404 ? "The DMS server does not have the AI connector installed yet." : (s.error || "Server unreachable")); return; } setErr(""); setStatus(s.data); const [c, ch, dr, us] = await Promise.all([aiApi("/connections"), aiApi("/changes?limit=200"), aiApi("/drafts"), s.data.isAdmin ? aiApi("/users") : Promise.resolve(null)]); if (c.ok) setConns(c.data.connections || []); if (ch.ok) setChanges(ch.data.changes || []); if (dr.ok) setDrafts(dr.data.drafts || []); if (us && us.ok) setUsers(us.data.users || []); }, []); React.useEffect(() => { load(); const t = setInterval(load, 30000); return () => clearInterval(t); }, [load]); const act = async (key, fn) => { setBusy(key); try { await fn(); } finally { setBusy(""); load(); } }; const setAccess = (u, access) => act("acc-" + u.id, async () => { const r = await aiApi(`/users/${encodeURIComponent(u.id)}/access`, "POST", { access }); aiToast(r.ok ? `${u.name || u.id}: AI access ${AI_ACCESS_LABEL[access]}${access === "none" ? " — any connection was cut" : ""}.` : r.error, r.ok ? "ok" : "warn"); }); const revoke = (c) => act("rev-" + c.id, async () => { if (!confirm(`Disconnect ${c.app} for ${c.userName || c.user}? It will have to sign in again.`)) return; const r = await aiApi(`/connections/${c.id}/revoke`, "POST", {}); aiToast(r.ok ? `${c.app} disconnected.` : r.error, r.ok ? "ok" : "warn"); }); const undo = (c) => act("undo-" + c.id, async () => { if (!confirm(`Undo this change?\n\n${c.summary}`)) return; const r = await aiApi(`/changes/${c.id}/undo`, "POST", {}); aiToast(r.ok ? "Change undone." : r.error, r.ok ? "ok" : "warn"); }); const decide = (d, st) => act("dr-" + d.id, async () => { if (st === "Applied") { if (d.kind === "commission-plan") { if (!confirm(`Make "${d.name}" the live pay plan?`)) return; // RC28 (fx, finding 3): applying the draft saves a NEW server pay plan version (the owner, Admin / F&I or the Office Manager) — never a per-station copy const r = D && D.setPayPlanConfig ? await D.setPayPlanConfig((d.content || {}).plan || {}, { reason: `AI Apps draft "${d.name}" applied` }) : { ok: false, msg: "Pay plan module not loaded." }; if (!r.ok) { aiToast(r.msg, "warn"); return; } } else if (d.kind === "journal-entry") { const c = d.content || {}; if (!confirm(`Post this journal entry (${aiMoney(c.total)}) to the GL now?`)) return; const r = D && D.postJournalEntry ? D.postJournalEntry({ date: c.date ? new Date(c.date + "T12:00:00") : undefined, desc: c.desc, ref: d.id, lines: c.lines, src: "Manual" }) : { ok: false, msg: "Accounting module not loaded." }; if (!r.ok) { aiToast(r.msg, "warn"); return; } } else if (!confirm(`Accept the ${d.name} budget?`)) return; } const r = await aiApi(`/drafts/${encodeURIComponent(d.id)}/status`, "POST", { status: st }); aiToast(r.ok ? `Draft ${st.toLowerCase()}.` : r.error, r.ok ? "ok" : "warn"); }); if (err) return (
{err}
); if (!status) return
Loading…
; const isAdmin = status.isAdmin; const pending = drafts.filter(d => d.status === "Draft (inactive)"); return (
{isAdmin && users && ( Who may connect Claude to K2B. Read lets it look things up. Update also lets it make changes the person approves first (next steps on leads, bank-rec notes, drafts). It always works inside the person's own role — a salesperson still only sees their own leads. Setting None cuts any open connection at once.}> {users.filter(u => u.active).map(u => ( ))}
UserRoleAI access
{u.name || u.id} {u.id} {u.role}
{["none", "read", "update"].map(a => ( ))}
)} ↻ Refresh} sub={isAdmin ? "Every AI app anyone has connected. Disconnect ends it immediately." : "AI apps connected with your sign-in. Disconnect ends it immediately."}> {conns.length === 0 ?
Nothing connected.
: ( {isAdmin && }{conns.map(c => ( {isAdmin && } ))}
AppUserAccessConnectedLast used
{c.app}{c.userName || c.user}{c.access === "update" ? "Read + approved changes" : "Read only"} {aiFmt(c.connectedAt)}{aiFmt(c.lastUsed)}
)}
{(pending.length > 0 || drafts.length > 0) && ( {drafts.slice(0, 40).map(d => (
{d.name} · {d.kind.replace("-", " ")} · {d.createdByName || d.createdBy} · {aiFmt(d.createdAt)} {d.status}
{d.notes && (open === d.id ?
{d.notes}
: )}
{d.kind === "budget" && } {d.status === "Draft (inactive)" && <> }
))}
)} {changes.length === 0 ?
No changes yet.
: ( {changes.map(c => ( ))}
WhenWhoChange
{aiFmt(c.at)} {c.userName || c.user}
via {c.app}
{c.summary}{c.undoneAt &&
Undone {aiFmt(c.undoneAt)} by {c.undoneBy}
}
{!c.undoneAt && (isAdmin || c.user === status.me.id) && }
)}
); }; const AiHeader = ({ status }) => { const url = status ? status.connectUrl : (location.origin + "/api/mcp"); const access = status ? status.me.access : "none"; const copy = () => { try { navigator.clipboard.writeText(url); aiToast("Connector address copied."); } catch (e) {} }; return (
AI Apps — Claude
Claude can work in K2B as you: chase cold leads, work the bank rec, check month-end, draft pay plans and budgets. It signs in with your own K2B login (no API keys), sees only what your role sees, asks before every change, and can never send messages, post or delete records, pay bills or switch on plans and budgets.
Connector address {url}
Your AI access: {AI_ACCESS_LABEL[access] || "None"}
{access === "none" && status && !status.isAdmin &&
Ask an administrator to turn on AI access for you before connecting.
}
); }; // RC28 (fx, the reviewer's finding 3): a pay plan applied here is saved ONCE on the server as a new version (decide() above) — every // station reads the server's plan (DMSCore.payPlanConfig); the old per-station copy (each PC applying the draft to itself) is gone. // Sidebar route "aiapps" — every role (Settings is admin-only). const AiAppsWindow = ({ onClose, go }) => (
ESC-Close
); Object.assign(window, { AiAppsPanel, AiAppsWindow });