/* © 2026 K2B NETWORK SOLUTIONS LLC. All rights reserved. */ /* eslint-disable */ // RC28 (svc) — the SERVICE PACK screens. Everything here shows what the SERVER answers (/api/svc/…, DMSCore svc* API): // • window.SvcMenuButton / SvcMenuPresentation — the MENU PRESENTATION at write-up / on an open RO: a full-screen, tablet-friendly view // (and a print) of the due / overdue / coming-up services with the store's menu prices and the GOOD / BETTER / BEST packages; the // customer accepts / declines per item; "Save to RO" asks the server to add the accepted items as RO lines and to record the declines // on the RO and the vehicle (shown again next visit). Prices only — never a cost. Sales can't open it (the server refuses too). // • window.SvcMaintLookup — Service › Maintenance: any service login (techs too) pulls a vehicle's schedule by VIN + odometer. // • window.SvcReminders — Service › Reminders: due this month / overdue / declined last visit; a text (only with the customer's recorded // consent, inside the texting hours, never twice in the cooldown) one by one, or the office's daily batch. Every attempt is logged. // • window.SvcSunbitCollect — Service › Sunbit to collect (the office / Service Manager): Sunbit-funded ROs until the deposit clears. // • window.SvcSunbitBox — the "Pay over time with Sunbit - apply in about 30 seconds" box (link + QR) — NO payment estimates or rates. // • window.SvcSetupTab — Service settings tabs: Maintenance schedule (store table + where it comes from), Service menu (prices, labor ops, // parts kits, hours, packages), Reminders (consent type, texting hours), Sunbit (apply link, show on documents, accounts). // Top-level names start with svc / Svc (one shared Babel scope). const svcM$ = (c) => (c == null || !isFinite(c) ? "—" : (c < 0 ? "-$" : "$") + (Math.abs(c) / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })); const svcMi = (n) => (n == null || !isFinite(n) ? "—" : Number(n).toLocaleString("en-US") + " mi"); const svcLbl = { fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.04em", color: "var(--jb-text-muted, #667)" }; const svcpInp = { width: "100%", boxSizing: "border-box", fontSize: 13, padding: "6px 8px", border: "1px solid var(--jb-border, #C8CCD2)", borderRadius: 4, fontFamily: "var(--jb-font)", background: "var(--jb-surface, #fff)", color: "var(--jb-text, #222)" }; const svcCard = { border: "1px solid var(--jb-border, #ccd)", borderRadius: 8, background: "var(--jb-surface, #fff)", padding: 12 }; const svcToast = (r, okMsg) => { try { window.k2bToast && window.k2bToast((r && (r.msg || r.message || r.error)) || okMsg || "Done.", r && r.ok ? "ok" : "warn", 4500); } catch (e) {} return r; }; const svcB64 = (svg) => { try { return "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svg))); } catch (e) { return ""; } }; const SVC_STATUS = { overdue: { label: "Overdue", bg: "#FBE9E7", fg: "#B23A2E" }, due: { label: "Due now", bg: "#FFF4D6", fg: "#8A5A00" }, coming: { label: "Coming up", bg: "#E5EFFA", fg: "#1456A0" }, later: { label: "Later", bg: "#EEF1F5", fg: "#556" } }; const svcEsc = (s) => String(s == null ? "" : s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); // the Sunbit box is loaded once the station is online, and again when the settings change (function svcBoot() { const D = window.DMS; if (!D || !D.svcLoadBadge) return; // the badge (every role prints customer papers) and — for the service roles — the settings with what this login may do (the server's answer) const go = () => { try { if (D.getAuthToken && D.getAuthToken() && (!D.connState || D.connState() === "online")) { D.svcLoadBadge(); if (D.svcCanPresent && D.svcCanPresent()) D.svcLoadConfig(); } } catch (e) {} }; try { D.onConn && D.onConn((st) => { if (st === "online") go(); }); } catch (e) {} setTimeout(go, 1200); setTimeout(go, 6000); })(); /* ═══ the Sunbit box (screen) ═══ */ const SvcSunbitBox = ({ compact }) => { const [, force] = React.useReducer(n => n + 1, 0); React.useEffect(() => (window.DMS && DMS.subscribe ? DMS.subscribe(force) : undefined), []); React.useEffect(() => { try { if (window.DMS && DMS.svcLoadBadge && !DMS.svcBadge()) DMS.svcLoadBadge(); } catch (e) {} }, []); const b = window.DMS && DMS.svcBadge ? DMS.svcBadge() : null; if (!b || !b.show || !b.url) return null; const src = b.qrSvg ? svcB64(b.qrSvg) : ""; return (
{src ? QR code for the Sunbit application : null}
{b.title || "Pay over time with Sunbit"} - {b.line || "apply in about 30 seconds"}
{b.url}
Scan the code with your phone camera or open the link to apply.
); }; /* ═══ the menu presentation ═══ */ window.k2bSvcPresentationHtml = function (p, sel) { if (!p) return ""; sel = sel || { accept: [], decline: [], packageId: null }; const pkg = (p.packages || []).find(x => x.id === sel.packageId) || null; const inPkg = (k) => !!(pkg && pkg.kinds.indexOf(k) !== -1); const mark = (k) => (sel.accept.indexOf(k) !== -1 || inPkg(k) ? "☑ Accepted" + (inPkg(k) ? " (package)" : "") : sel.decline.indexOf(k) !== -1 ? "☒ Declined" : "☐ Accept ☐ Decline"); const rows = (st) => (p.items || []).filter(i => i.status === st).map(i => `${svcEsc(i.menu ? i.menu.label : i.service)}
${svcEsc(i.why || "")}
${i.menu ? svcM$(i.menu.priceCents) : "ask your advisor"}${i.onRo ? "on this RO" : mark(i.kind)}`).join(""); const sect = (st, title) => { const r = rows(st); return r ? `

${title}

${r}
ServicePriceYour choice
` : ""; }; const pk = (p.packages || []).map(x => `${svcEsc(x.tierLabel)} — ${svcEsc(x.name)}
${svcEsc(x.includes.join(", "))}
${svcM$(x.priceCents)}
${sel.packageId === x.id ? "
☑ Chosen
" : ""}`).join(""); const declinedBefore = (p.declinedLastVisit || []).length ? `

Declined at the last visit: ${svcEsc(p.declinedLastVisit.map(d => d.service + " (" + d.roId + ")").join(", "))}

` : ""; const total = (sel.accept || []).reduce((s, k) => { const it = (p.items || []).find(i => i.kind === k); return s + (it && it.menu ? it.menu.priceCents : 0); }, 0) + (pkg ? pkg.priceCents : 0); const head = typeof dealerHeadHtml === "function" ? dealerHeadHtml() : ""; return `${head}
RECOMMENDED SERVICE
${svcEsc(p.roId)} · ${svcEsc(p.customer || "")} · ${svcEsc(p.vehicle || "")} · VIN ${svcEsc(p.vin || "")} · ${svcEsc(svcMi(p.odometer))}
${sect("overdue", "Overdue")}${sect("due", "Due now")}${sect("coming", "Coming up")} ${pk ? `

Packages

${pk}
` : ""} ${declinedBefore}
Accepted today (before tax)${svcM$(total)}

${svcEsc(p.taxNote || "Prices are before tax.")}

${window.k2bSunbitBoxHtml ? window.k2bSunbitBoxHtml(p.sunbit) : ""}
Customer · Date
Service Advisor · Date
`; }; const SvcItemCard = ({ it, sel, pkg, toggle }) => { const st = SVC_STATUS[it.status] || SVC_STATUS.later; const inPkg = !!(pkg && pkg.kinds.indexOf(it.kind) !== -1); const acc = sel.accept.indexOf(it.kind) !== -1, dec = sel.decline.indexOf(it.kind) !== -1; const big = { flex: 1, minHeight: 46, fontSize: 15, fontWeight: 800, borderRadius: 8, cursor: "pointer", border: "2px solid" }; return (
{it.menu ? it.menu.label : it.service}
{it.menu ? svcM$(it.menu.priceCents) : ask your advisor}
{it.noRecordDue ? "Recommended" : st.label}{it.why}
{it.onRo ?
✓ Already on this repair order
: inPkg ?
✓ In the {pkg.tierLabel} package
: (
)}
); }; const SvcMenuPresentation = ({ roId, onClose }) => { const D = window.DMS; const [p, setP] = React.useState(null); const [err, setErr] = React.useState(""); const [sel, setSel] = React.useState({ accept: [], decline: [], packageId: null }); const [busy, setBusy] = React.useState(false); const load = async () => { setErr(""); const r = await D.svcPresentation(roId); if (r.ok) setP(r); else setErr(r.msg || "The menu could not be loaded."); }; React.useEffect(() => { load(); }, [roId]); const pkg = p ? (p.packages || []).find(x => x.id === sel.packageId) || null : null; const toggle = (k, how) => setSel(s => { const accept = s.accept.filter(x => x !== k), decline = s.decline.filter(x => x !== k); if (how === "accept" && s.accept.indexOf(k) === -1) accept.push(k); if (how === "decline" && s.decline.indexOf(k) === -1) decline.push(k); return { ...s, accept, decline }; }); const pickPkg = (id) => setSel(s => { const next = s.packageId === id ? null : id; const pk = next ? (p.packages || []).find(x => x.id === next) : null; return { packageId: next, accept: s.accept.filter(k => !(pk && pk.kinds.indexOf(k) !== -1)), decline: s.decline.filter(k => !(pk && pk.kinds.indexOf(k) !== -1)) }; }); const total = p ? sel.accept.reduce((s, k) => { const it = p.items.find(i => i.kind === k); return s + (it && it.menu ? it.menu.priceCents : 0); }, 0) + (pkg ? pkg.priceCents : 0) : 0; const save = async () => { if (!sel.accept.length && !sel.decline.length && !sel.packageId) { svcToast({ ok: false, msg: "Accept or decline at least one service first." }); return; } setBusy(true); try { const r = await D.svcPresentCommit(roId, sel); svcToast(r); if (r.ok) { setSel({ accept: [], decline: [], packageId: null }); await load(); } } finally { setBusy(false); } }; const print = () => { if (p && window.k2bPrint) window.k2bPrint(window.k2bSvcPresentationHtml(p, sel), { title: "Recommended Service " + roId }); }; const sections = [["overdue", "Overdue"], ["due", "Due now"], ["coming", "Coming up"]]; return (
Recommended service
{p ? `${p.customer || ""} · ${p.vehicle || ""} · ${svcMi(p.odometer)} · ${roId}` : roId}
{err &&
{err}
} {!p && !err &&
Looking up the schedule…
} {/* RC28 fix (svc-aud finding 9): this is the CUSTOMER's screen — the staff notes (starter rows, provider state, odometer check) are not shown here; Service Dept › Maintenance schedules shows them */} {p && sections.map(([st, title]) => { const list = p.items.filter(i => i.status === st); if (!list.length) return null; return (
{title} ({list.length})
{list.map(it => )}
); })} {p && !p.items.length &&
Nothing is due, overdue or coming up for this vehicle at {svcMi(p.odometer)}.
} {p && (p.packages || []).length > 0 && (
Packages
{p.packages.map(x => ( ))}
)} {p && (p.declinedLastVisit || []).length > 0 && (
Declined at the last visit
{p.declinedLastVisit.map((d, i) =>
{d.service} — {d.roId} · {String(d.at).slice(0, 10)}{d.priceCents != null ? " · was " + svcM$(d.priceCents) : ""}
)}
)} {p && (
Accepted today: {svcM$(total)} {p.taxNote}
{sel.accept.length + (pkg ? 1 : 0)} accepted · {sel.decline.length} declined
)} {p && }
); }; // RC32 (lane ros): the button says how many services are due ("📋 Service menu · 3 due") — the same server answer the presentation // reads (GET /api/svc/present, read only), asked once per RO / VIN / odometer and kept for 10 minutes; any refusal shows no count. const SVC_DUE_CACHE = new Map(); const svcDueKey = (ro) => [ro.id, ro.vin || "", ro.miles || (ro.veh && ro.veh.miles) || ""].join("|"); const SvcMenuButton = ({ ro }) => { const [open, setOpen] = React.useState(false); const D = window.DMS; const may = !!(D && D.svcCanPresent && D.svcCanPresent()); const key = svcDueKey(ro); const [due, setDue] = React.useState(() => { const c = SVC_DUE_CACHE.get(key); return c && Date.now() - c.t < 600000 ? c.n : null; }); React.useEffect(() => { if (!may || !ro.vin || ro.status === "Closed" || !D.svcPresentation) return; // RC32 fix: an RO that is not in the store (Setup › "Preview an RO") asks the server nothing if (!(D.repairOrders && (D.repairOrders() || []).some(r => r && r.id === ro.id))) return; const c = SVC_DUE_CACHE.get(key); if (c && Date.now() - c.t < 600000) { setDue(c.n); return; } let live = true; const t = setTimeout(async () => { try { const r = await D.svcPresentation(ro.id); const n = r && r.ok && Array.isArray(r.items) ? r.items.filter(i => (i.status === "due" || i.status === "overdue") && !i.onRo).length : null; SVC_DUE_CACHE.set(key, { n, t: Date.now() }); if (live) setDue(n); } catch (e) { /* no count — the button still opens the menu */ } }, 1200); return () => { live = false; clearTimeout(t); }; }, [key, may, open]); if (!may) return null; return ( <> {open && { SVC_DUE_CACHE.delete(key); setOpen(false); }} />} ); }; /* ═══ Service › Maintenance (look up a schedule) ═══ */ const SvcMaintLookup = () => { const D = window.DMS; const [f, setF] = React.useState({ vin: "", miles: "", severe: false }); const [r, setR] = React.useState(null); const [busy, setBusy] = React.useState(false); const go = async () => { setBusy(true); try { setR(await D.svcSchedule(f.vin.trim(), String(f.miles).replace(/[^0-9]/g, ""), { severe: f.severe })); } finally { setBusy(false); } }; return (
{r && !r.ok &&
{r.msg || r.error}
} {r && r.ok && (
{[r.vehicle.year, r.vehicle.make, r.vehicle.model].filter(Boolean).join(" ") || r.vin} · {svcMi(r.odometer)} · source: {r.source === "store" ? "store schedule table" : (r.provider && r.provider.label) || r.source}{r.severe ? " · severe" : ""}
{r.inService &&
In service since {r.inService} ({r.inServiceHow || "estimated"})
} {(r.notes || []).map((n, i) =>
{n}
)}
{["Service", "Status", "Every", "Last on record", "Next"].map(h => )}{r.items.map((x, i) => { const st = SVC_STATUS[x.status] || SVC_STATUS.later; return ( ); })}
{h}
{x.service}{x.starter ? · starter : null} {x.noRecordDue ? "Due · no record" : st.label} {[x.intervalMiles ? svcMi(x.intervalMiles) : "", x.intervalMonths ? x.intervalMonths + " months" : "", x.dueMiles ? "due at " + svcMi(x.dueMiles) : ""].filter(Boolean).join(" / ")} {x.onRecord ? `${x.lastMiles != null ? svcMi(x.lastMiles) + " · " : ""}${x.lastDate || ""} (${x.lastFrom})` : "not on record"} {x.why}
)}
); }; /* ═══ Service › Reminders ═══ */ const SvcReminders = () => { const D = window.DMS; const [view, setView] = React.useState("due"); const [r, setR] = React.useState(null); const [log, setLog] = React.useState(null); const [busy, setBusy] = React.useState(""); const [find, setFind] = React.useState(""); const SHOW = 300; // a big store's "All" can be thousands of vehicles: show the first 300 (the most overdue first), type to narrow const load = async (v) => { setR(await D.svcReminders(v || view)); }; React.useEffect(() => { load(view); }, [view]); const send = async (vin) => { setBusy(vin); try { const x = await D.svcRemind(vin); svcToast({ ok: !!x.sent, msg: x.message || x.msg }); await load(); } finally { setBusy(""); } }; const batch = async () => { if (!window.confirm("Send today's reminder texts to every customer on the list who has recorded text consent?\nEach text is checked again (consent, STOP, texting hours, cooldown) and logged.")) return; setBusy("batch"); try { const x = await D.svcRemindBatch("both"); svcToast({ ok: x.ok, msg: x.msg || x.message || x.error }); await load(); } finally { setBusy(""); } }; const views = [["due", "Due this month"], ["overdue", "Overdue"], ["declined", "Declined last visit"], ["all", "All"]]; return (
{views.map(([k, l]) => )} setFind(e.target.value)} style={{ minWidth: 200, flex: "0 1 260px" }} />
{r && r.may && r.may.batch && }
{r && r.quiet &&
{r.quiet}
} {r && !r.ok &&
{r.msg}
} {r && r.ok && (() => { const f = find.trim().toLowerCase(); const hit = f ? r.rows.filter(x => [x.vin, x.vehicle, x.customer].some(t => String(t || "").toLowerCase().includes(f)) || (window.DMS && window.DMS.custQueryHit ? window.DMS.custQueryHit(f, x.custNum) : false)) : r.rows; /* RC31 (fx31, F6) */ const shown = hit.slice(0, SHOW); return (
{hit.length > shown.length &&
Showing the first {shown.length} of {hit.length} — type a VIN, vehicle or customer to narrow the list (the daily batch still covers every vehicle).
}
{r.count} vehicle{r.count === 1 ? "" : "s"} · texts need the customer's recorded {r.rules && r.rules.consentType} consent · texting hours {r.rules && r.rules.sendStart}–{r.rules && r.rules.sendEnd} ({r.rules && r.rules.timeZone}) · one reminder per {r.rules && r.rules.cooldownDays} days
{["Vehicle", "Customer", "Last visit", "Mi / day", "Now (est.)", "Overdue", "Due this month", "Declined last visit", "Text", ""].map(h => )}{shown.map(x => ( ))}
{h}
{x.vehicle || "—"}
{x.vin}
{x.customer || "—"}
{x.phoneMasked}
{x.lastVisit.date}
{svcMi(x.lastVisit.miles)}
{x.milesPerDay}{(x.odometerNotes || []).length ?
⚠ odometer
: null}
{svcMi(x.projectedMiles)} {x.overdue.map(i => i.service).join(", ")} {x.dueThisMonth.map(i => i.service).join(", ")} {x.declinedLastVisit.map(i => i.service).join(", ")} {x.why}{x.lastSentAt ?
last text {x.lastSentAt.slice(0, 10)}
: null}
{r.may && r.may.send && }
); })()} {log && log.ok && (
Reminder log (every attempt)
{log.log.map(l => )}
{String(l.at).replace("T", " ").slice(0, 16)}{l.vin}{l.address}{l.status}{l.reason || ""}{l.batch_id || ""}
)}
); }; /* ═══ Service › Sunbit to collect ═══ */ const SvcSunbitCollect = () => { const D = window.DMS; const [all, setAll] = React.useState(false); const [r, setR] = React.useState(null); const [dep, setDep] = React.useState(null); const load = async (a) => setR(await D.svcToCollect(a === undefined ? all : a)); React.useEffect(() => { load(all); }, [all]); const canPost = !!(D.can && D.can("glEdit")); const post = () => { const amt = +dep.amount || 0; const r1 = D.postJournalEntry({ id: dep.id, date: dep.date, jrn: 56, src: "Sunbit", ref: dep.item.sunbitRef, desc: `Sunbit deposit ${dep.item.roId} ref ${dep.item.sunbitRef}`, extra: { sunbitPayId: dep.item.payId, sunbitRoId: dep.item.roId }, lines: [{ acct: dep.bank, dr: amt, cr: 0, desc: "Sunbit deposit " + dep.item.roId }, { acct: dep.item.recAcct, dr: 0, cr: amt, desc: "Clear Sunbit receivable " + dep.item.roId }] }); svcToast(r1); if (r1.ok) { setDep(null); setTimeout(() => load(), 1500); } }; return (
Sunbit to collect
{r && !r.ok &&
{r.msg}
} {r && r.ok && (
{r.openCount} open · {svcM$(r.openCents)} still to come from Sunbit · books: {r.books.map(b => `${b.acct} ${svcM$(b.balanceCents)}`).join(" · ")}
{r.note}
{["RO", "Customer", "Sunbit ref", "Funded", "Merchant fee", "Deposit expected", "Collected", "Status", ""].map(h => )}{r.items.map(x => ( ))}
{h}
{x.roId}
{x.receiptNo}
{x.customer || ""} {x.sunbitRef} {svcM$(x.amountCents)} {x.feeCents != null ? svcM$(x.feeCents) : ""} {svcM$(x.netCents)} {x.collectedCents != null ? svcM$(x.collectedCents) : ""} {x.status}{x.deposits && x.deposits.length ?
{x.deposits.join(", ")}
: null}
{x.status === "open" && canPost && }
{(r.unmatched || []).length > 0 && (
Credits to the Sunbit receivable that name no open Sunbit RO ({r.unmatchedCount || r.unmatched.length} · {svcM$(r.unmatchedCents)}) — they clear nothing on this list
If one of them is a Sunbit deposit, reverse it and post it again with the RO number or the Sunbit reference in the description (or use "Record the Sunbit deposit"). Other entries to this account (notes, loans …) belong here only because they share the account — ask the accountant about a separate "due from Sunbit" account.
{r.unmatched.map((u, i) => )}
{u.jeId}{u.date}{u.desc}{u.acct}{svcM$(u.cents)}{u.why}
)}
)} {dep && ( setDep(null)} width={480} buttons={<> setDep(null)}>CancelPost deposit}>
Posts an EFT deposit (journal 56): DR the bank / CR the Sunbit receivable {dep.item.recAcct}. Use the amount on the bank statement — the difference to {svcM$(dep.item.remainingCents)} stays on the list.
)}
); }; /* ═══ Service settings tabs ═══ */ const svcMoneyText = (c) => (c == null ? "" : (c / 100).toFixed(2)); const svcCents = (t) => { const s = String(t == null ? "" : t).replace(/[$,\s]/g, ""); if (!/^\d+(\.\d{1,2})?$/.test(s)) return null; const [a, b] = s.split("."); return Number(a) * 100 + Number(((b || "") + "00").slice(0, 2)); }; const SvcSetupTab = ({ tab }) => { const D = window.DMS; const [, force] = React.useReducer(n => n + 1, 0); React.useEffect(() => (D && D.subscribe ? D.subscribe(force) : undefined), []); React.useEffect(() => { if (D && D.svcLoadConfig) D.svcLoadConfig(); }, []); const cfg = D && D._svc && D._svc.config; const [draft, setDraft] = React.useState(null); React.useEffect(() => { setDraft(null); }, [tab]); if (!cfg) return
{(D && D._svc && D._svc.msg) || "Loading the service settings from the server…"}
; const st = cfg.settings, meta = cfg.meta, may = cfg.may; const save = async (key, value) => { const r = await D.svcSaveSetting(key, value, meta[key] ? meta[key].version : 0); svcToast(r); if (r.ok) setDraft(null); return r; }; const canSvc = may.setup, canOffice = may.office; if (tab === "schedule") { const rows = draft || st.schedule.map(r => ({ ...r })); const set = (i, k, v) => setDraft(rows.map((r, n) => (n === i ? { ...r, [k]: v, starter: false } : r))); // a row the store changes is the store's own (no longer "starter") const prov = st.provider; const starters = rows.filter(r => r.starter).length; return (
{cfg.providers.map(p =>
{p.label}: {p.connected ? "key on the server" : "not connected yet"}{p.lastError ? " — " + p.lastError : ""}
)}
A provider's key is entered only on the server: bash scripts/set-maint-provider.sh
The rows marked starter came with the DMS: {cfg.starterNote}. This is the store's own table — it is not GM's official schedule. Change the miles / months to what the store wants, then save.
{["Kind", "Service", "Every mi", "Every months", "Severe mi", "Severe months", "Labor op", "Parts hint", "Makes", "Active", ""].map(h => )}{rows.map((r, i) => ( {["intervalMiles", "intervalMonths", "severeMiles", "severeMonths"].map(k => )} ))}
{h}
set(i, "service", e.target.value)} style={svcpInp} />{r.starter ?
starter - confirm with the store / GM
: null}
set(i, k, e.target.value.replace(/[^0-9]/g, "") === "" ? null : +e.target.value.replace(/[^0-9]/g, ""))} style={{ ...svcpInp, width: 84 }} /> set(i, "laborOp", e.target.value.toUpperCase())} style={{ ...svcpInp, width: 100 }} /> set(i, "partsHint", e.target.value)} style={svcpInp} /> set(i, "applies", { ...(r.applies || {}), makes: e.target.value.split(",").map(x => x.trim().toUpperCase()).filter(Boolean) })} style={svcpInp} /> set(i, "active", e.target.checked)} /> {canSvc && }
{canSvc &&
{starters > 0 && }
{draft && }
} {!canSvc &&
View only — the office or the Service Manager keeps the schedule.
}
); } if (tab === "menu") { const m = draft || { items: st.menu.items.map(x => ({ ...x, priceText: svcMoneyText(x.priceCents), kit: (x.kit || []).map(p => ({ ...p, unitText: svcMoneyText(p.unitCents) })) })), packages: st.menu.packages.map(x => ({ ...x, priceText: svcMoneyText(x.priceCents) })) }; const setI = (i, k, v) => setDraft({ ...m, items: m.items.map((x, n) => (n === i ? { ...x, [k]: v } : x)) }); const setP = (i, k, v) => setDraft({ ...m, packages: m.packages.map((x, n) => (n === i ? { ...x, [k]: v } : x)) }); const out = () => ({ items: m.items.map(x => ({ kind: x.kind, label: x.label, laborOp: x.laborOp, hours: x.hours, price: x.priceText, active: x.active !== false, kit: (x.kit || []).map(p => ({ part: p.part, desc: p.desc, qty: p.qty, unit: p.unitText })) })), packages: m.packages.map(x => ({ id: x.id, tier: x.tier, name: x.name, kinds: x.kinds, laborOp: x.laborOp, price: x.priceText, active: x.active !== false })) }); const missing = cfg.kinds.filter(k => !m.items.some(x => x.kind === k)); return (
The customer's price of each service (before tax) — the presentation shows these prices only (never a cost). The parts kit is added to the RO as job parts at these prices; the labor line is the price less the kit.
{m.items.map((x, i) => (
Parts kit {(x.kit || []).map((p, j) => (
setI(i, "kit", x.kit.map((q, n) => (n === j ? { ...q, part: e.target.value.toUpperCase() } : q)))} style={svcpInp} /> setI(i, "kit", x.kit.map((q, n) => (n === j ? { ...q, desc: e.target.value } : q)))} style={svcpInp} /> setI(i, "kit", x.kit.map((q, n) => (n === j ? { ...q, qty: e.target.value.replace(/[^0-9.]/g, "") } : q)))} style={svcpInp} /> setI(i, "kit", x.kit.map((q, n) => (n === j ? { ...q, unitText: e.target.value.replace(/[^0-9.,]/g, "") } : q)))} style={svcpInp} /> {canSvc ? : }
))} {canSvc && } {canSvc && }
))} {canSvc && missing.length > 0 &&
{missing.map(k => )}
}
GOOD / BETTER / BEST packages
{m.packages.map((x, i) => (
{m.items.map(it => )} {canSvc && }
))} {canSvc &&
{draft && }
} {!canSvc &&
View only — the office or the Service Manager keeps the menu.
}
); } if (tab === "reminders" || tab === "sunbit") { const rs = draft && draft.k === "reminders" ? draft.v : { ...st.reminders }; const sb = draft && draft.k === "sunbit" ? draft.v : { ...st.sunbit }; const setR = (k, v) => setDraft({ k: "reminders", v: { ...rs, [k]: v } }); const setS = (k, v) => setDraft({ k: "sunbit", v: { ...sb, [k]: v } }); return (
Sunbit (pay over time)
The box says only "Pay over time with Sunbit - apply in about 30 seconds" with the link and a QR code — never a monthly payment, a rate or approval odds.
{canOffice &&
}
Reminder texts
A reminder is texted only to a customer with recorded consent (Messaging Center), never after STOP, only inside these hours (8:00 – 21:00 at most), with the approved "Service due reminder" template, and never twice within the days above. Every attempt is logged.
{canOffice &&
}
{!canOffice &&
View only — the office sets Sunbit and the reminder rules.
}
); } return null; }; Object.assign(window, { SvcSunbitBox, SvcMenuPresentation, SvcMenuButton, SvcMaintLookup, SvcReminders, SvcSunbitCollect, SvcSetupTab });