/* 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 — Service Repair Orders. // RC32 (lane ros, owner 2026-10-25 "the RO screens are way too crowded"): "one step at a time". // • No RO open: the full-width RO list, grouped by the 5 steps (Write-up · Waiting · In progress · Ready · Closed), or a Board. // • An RO open: a slim list on the left (or a thin strip on a small laptop; behind "☰ RO list" on a tablet / phone) and the RO: // a 2-line header (⋯ More holds the rarely used buttons), the 5-step line with ONE yellow next-step button, tabs (Jobs · // Customer & vehicle · Notes · Inspection · Payments · ⋯ More) and a short money line with Details. // • Jobs fold to one line each; the job being priced (Write-up) or worked (In progress) opens by itself; the Customer OK card sits on // top until the customer approves; the payment form opens at Ready (owner Q4). // • Every move between steps goes through DMS.svcMoveTo (ServiceCore, RC32 foundation): one stored step at a time through today's // Advance check — never past "Awaiting Approval" without the customer's OK. Money is untouched: Close RO & Post, payments, voids, // refunds and the Sunbit close are the same server commands; only the buttons moved. // • Settings are read ONLY through DMS.svcSettings() / the svc* helpers (RC32 foundation); a store that never saved them reads the // defaults (INTERFACE.md section 2); "Aa My view" is the person's own view (browser only). // • Phones: one column, a pinned bottom bar (🖨 Invoice | the yellow button | Close RO). One DOM tree per layout, picked in JS from the // window's width (desk ≥ 1000, narrow 600-999, phone < 600) — the hooks the tests use exist exactly once. // Logic lives in ServiceCore / DMSCore. // Today's lists (RC31). The screens read the store's lists from the settings (L4 op catalog, L1 advisors, L3 inspection, L5 methods); // these stay as the "today" values other screens and the foundation test compare with. const SVC_OP_CATALOG = [ { op: "OIL-LOF", desc: "Oil Change Lube Filter", hrs: 0.5, lab: 65.50 }, { op: "OIL-SYN", desc: "Synthetic Oil Change", hrs: 0.5, lab: 79.50 }, { op: "BRK-PADS-F", desc: "Front Brake Pad Replacement", hrs: 1.5, lab: 189.00 }, { op: "BRK-PADS-R", desc: "Rear Brake Pad Replacement", hrs: 1.5, lab: 189.00 }, { op: "BRK-RTR-F", desc: "Front Rotor Replacement", hrs: 1.2, lab: 158.00 }, { op: "TIRE-ROT", desc: "Tire Rotation", hrs: 0.3, lab: 32.40 }, { op: "TIRE-MNT", desc: "Mount and Balance — 4 Tires", hrs: 0.8, lab: 98.00 }, { op: "ALIGN-4W", desc: "4-Wheel Alignment", hrs: 1.0, lab: 129.00 }, { op: "DIAG-CEL", desc: "Diagnostic — Check Engine Light", hrs: 1.0, lab: 129.00 }, { op: "DIAG-AC", desc: "Diagnostic — Climate / A/C", hrs: 1.0, lab: 129.00 }, { op: "BATT-RPL", desc: "Battery Replacement", hrs: 0.4, lab: 48.00 }, { op: "STATE-INSP", desc: "MO State Safety Inspection", hrs: 0.5, lab: 12.00 }, { op: "WARR-PCM", desc: "Warranty — PCM Reflash", hrs: 1.2, lab: 142.00 }, { op: "SVC-30K", desc: "30,000 Mile Service", hrs: 2.5, lab: 318.00 }, { op: "SVC-60K", desc: "60,000 Mile Service", hrs: 3.5, lab: 442.00 }, { op: "LABOR", desc: "Custom Labor Operation", hrs: 1.0, lab: 145.00 }, ]; const SVC_ADVISORS = ["Mark Service", "Shannon Keyes", "Bryce Tanner", "Delaney Riley"]; const OP_STATUS_TONE = { DONE: "var(--jb-green)", ACTIVE: "var(--jb-blue)", PEND: "var(--jb-amber)", HOLD: "var(--jb-red)" }; const RO_PAY_TYPE_TONE = { Customer: "#1B66A8", Warranty: "#B26A00", "Service Contract": "#0E7A6E", Internal: "#8050B0", Courtesy: "#8a1c1c" }; const roAgeDays = (ro) => Math.floor((Date.now() - new Date(ro.opened)) / 86400000); const roM$ = (n) => "$" + (Number(n) || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); // Size of an element (ResizeObserver) — drives the layout (desk / narrow / phone). // RC32: a CALLBACK ref — the window can mount after the first render (the technician's phone shows My jobs first), so the observer // (re)attaches whenever the element appears; the first width is read at once (before paint) so the right layout draws first time. const useRoBox = () => { const [box, setBox] = React.useState(() => ({ w: typeof window !== "undefined" && window.innerWidth ? Math.max(320, window.innerWidth - (window.innerWidth >= 1100 ? 244 : window.innerWidth > 640 ? 68 : 0)) : 1400, h: 900 })); const cleanup = React.useRef(null); const ref = React.useCallback((el) => { if (cleanup.current) { cleanup.current(); cleanup.current = null; } if (!el) return; const upd = () => setBox(b => (b.w === el.clientWidth && b.h === el.clientHeight ? b : { w: el.clientWidth, h: el.clientHeight })); upd(); if (typeof ResizeObserver === "undefined") { window.addEventListener("resize", upd); cleanup.current = () => window.removeEventListener("resize", upd); return; } const ro = new ResizeObserver(upd); ro.observe(el); cleanup.current = () => ro.disconnect(); }, []); React.useEffect(() => () => { if (cleanup.current) cleanup.current(); }, []); return [ref, box]; }; // ─── RC32 helpers: settings, steps, who, what the RO is waiting on ────── // The store's settings, resolved by the foundation (every missing / broken setting reads its default). Never read serviceSettings.ro raw. const roCfgOf = () => { const D = window.DMS; try { const s = D && D.svcSettings ? D.svcSettings() : null; if (s && s.ro && s.money) return s; } catch (e) {} const t = (D && D.SVC_RO_TODAY) || { ro: null, money: null }; return { ro: t.ro, money: t.money }; }; const RO_GROUPS = ["writeup", "waiting", "progress", "ready", "closed"]; const RO_GROUP_META = { writeup: { n: 1, label: "Write-up", hint: "get the customer's OK, then send it to the shop", dot: "#D99A1E", bg: "#FFF6E2", fg: "#8A5A00" }, waiting: { n: 2, label: "Waiting", hint: "sent to the shop, nobody working on it yet", dot: "#8050B0", bg: "#F1EAFB", fg: "#5E3596" }, progress: { n: 3, label: "In progress", hint: "a technician is working", dot: "#1B66A8", bg: "#E5EFFA", fg: "#1456A0" }, ready: { n: 4, label: "Ready", hint: "work done: call the customer, take payment, close", dot: "#16A34A", bg: "#E6F7EC", fg: "#0E7A37" }, closed: { n: 5, label: "Closed", hint: "posted to the books", dot: "#9AA3AE", bg: "#EEF1F5", fg: "#5B6578" }, }; const roGroupLabel = (g) => { try { const x = (window.DMS && DMS.SVC_STEP_GROUPS || []).find(s => s.key === g); if (x && x.label) return x.label; } catch (e) {} return (RO_GROUP_META[g] || {}).label || g; }; // cfg (optional) = a resolved settings object (Service Setup's "Preview an RO" passes the unsaved choices); missing = the station's settings const roGroupOf = (ro, cfg) => { try { return window.DMS && DMS.svcStepGroup ? DMS.svcStepGroup(ro, cfg && cfg.ro ? { settings: cfg } : undefined) : (ro.status === "Closed" ? "closed" : "writeup"); } catch (e) { return ro && ro.status === "Closed" ? "closed" : "writeup"; } }; const roLive = (ro) => ((ro && ro.ops) || []).filter(j => j && j.approval !== "declined"); const roOkOf = (ro) => { try { return DMS.svcOkState(ro); } catch (e) { return { given: !!ro.authorization, needed: !ro.authorization && roLive(ro).length > 0 }; } }; // What the customer owes NOW. Open RO: the shared calculation. Closed RO: the committed balance less the payments recorded after the // close (their own PMT- / RF- entries) — screen only; fixes the closed, paid RO that still said "customer owes" (ux-shots finding). const roOwesNow = (ro) => { const m = window.DMS && DMS.roMoney ? DMS.roMoney(ro) : null; if (!m || (ro && ro.status === "Void")) return 0; // RC36 (rov36): a VOID / REVERSED RO owes nothing const bal = +m.customerBalance || 0; if (ro.status !== "Closed") return Math.max(0, bal); let after = 0; (ro.payments || []).forEach(p => { if (p && !p.voided && p.jeId && /^(PMT|RF)-/.test(String(p.jeId))) after += +p.amount || 0; }); return Math.max(0, Math.round((bal - after) * 100) / 100); }; const roTime = (iso) => { const d = new Date(iso); return isNaN(d) ? "" : d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); }; const roPromiseText = (ro) => { if (!ro || !ro.promise) return ""; const d = new Date(ro.promise); if (isNaN(d)) return ""; const now = new Date(), t = roTime(ro.promise); if (d.toDateString() === now.toDateString()) return t; if (d.toDateString() === new Date(now.getTime() + 86400000).toDateString()) return "Tomorrow " + t; return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }) + " " + t; }; const roPromiseLate = (ro) => { if (!ro || !ro.promise || ro.status === "Closed") return false; const d = new Date(ro.promise); return !isNaN(d) && d.getTime() < Date.now() && roGroupOf(ro) !== "ready"; }; const roStatusWord = (code, cfg) => { const w = cfg && cfg.ro && cfg.ro.lists && cfg.ro.lists.jobStatus; return (w && w[code]) || code || "PEND"; }; // "Waiting on / next step" — plain words for the list, the board and the slim cards. const roWaitingOn = (ro, owes) => { if (ro && ro.status === "Void") return { text: (ro.roVoid && ro.roVoid.kind === "reversed" ? "REVERSED" : "VOID") + (ro.roVoid && ro.roVoid.reasonLabel ? " · " + ro.roVoid.reasonLabel : ""), dot: "#B23A2E" }; // RC36 (rov36) const g = roGroupOf(ro); if (g === "closed") return { text: owes > 0.005 ? "Closed · owes " + roM$(owes) : "Closed", dot: RO_GROUP_META.closed.dot }; const live = roLive(ro); if (!(ro.ops || []).length) return { text: "Add jobs", dot: "#C7CDD4" }; const hold = live.find(j => j.status === "HOLD"); if (hold) return { text: "On hold" + (hold.holdReason ? " · " + hold.holdReason : ""), dot: "#B23A2E" }; const ok = roOkOf(ro); if (ok.needed && g !== "ready") { const sent = ro.signRequest && ro.signRequest.status === "sent" ? " · e-sign sent " + roTime(ro.signRequest.sentAt) : ""; return { text: "Customer OK needed" + sent, dot: RO_GROUP_META.writeup.dot }; } if (g === "writeup") return { text: "Send to the shop", dot: RO_GROUP_META.writeup.dot }; if (g === "waiting") { const w = ro.payType === "Warranty" || live.some(j => j.payType === "Warranty") ? " · warranty" : ""; return { text: (ro.stage === "In Diagnosis" ? "Diagnosis" : ro.stage === "Estimate" ? "Estimate" : "Waiting for tech") + w, dot: RO_GROUP_META.waiting.dot }; } if (g === "progress") { const done = live.filter(j => j.status === "DONE").length; return { text: "Working" + (done ? ` · ${done} of ${live.length} jobs done` : ""), dot: RO_GROUP_META.progress.dot }; } return { text: owes > 0.005 ? "Customer owes " + roM$(owes) : "Ready to close", dot: RO_GROUP_META.ready.dot }; }; // Who is "me": the technicians linked to this sign-in (T1, technicians[].userId) and the advisor name of this sign-in. const roMeIds = () => { const D = window.DMS; const me = String((D && D.userId && D.userId()) || ""); const techIds = me ? ((D && D.technicians && D.technicians()) || []).filter(t => t && t.userId != null && String(t.userId) === me).map(t => t.id) : []; const u = ((D && D.roster && D.roster()) || []).find(x => x && String(x.id) === me); return { me, techIds, advisor: u ? String(u.name || u.id || "") : "" }; }; const roIsMine = (ro, who) => !!who && ( (who.techIds.length && ((ro.ops || []).some(o => who.techIds.includes(o.techId)) || who.techIds.includes(ro.techId))) || (who.advisor && ro.advisor === who.advisor) || (who.me && String(ro.openedBy || "") === who.me)); const roMyViewGet = () => { try { return window.DMS && DMS.svcMyView ? DMS.svcMyView() : null; } catch (e) { return null; } }; const roRoleViewGet = () => { try { const v = window.DMS && DMS.svcRoleView ? DMS.svcRoleView() : null; if (v) return v; } catch (e) {} return { chip: "open", counters: "tiles", acct: "tab", acctDetail: "open", tabs: ["cust", "jobs", "notes", "pay"] }; }; const roRoleKey = () => { try { return window.DMS && DMS.svcRoleKey ? DMS.svcRoleKey() : "advisor"; } catch (e) { return "advisor"; } }; const roDeviceType = () => { try { return window.DMS && DMS.svcDeviceType ? DMS.svcDeviceType() : "laptop"; } catch (e) { return "laptop"; } }; const roSay = (r) => { if (r && r.msg) window.k2bToast && window.k2bToast(r.msg, r.ok ? "ok" : "warn", 3200); return r || { ok: false }; }; // R3 (owner Q3): after a change that may move the RO, move it by itself — only through svcMoveTo (one stored step at a time, never past the OK) // RC32 fix (r32-aud M1, owner Q1): when the customer OKs the WHOLE estimate (X4 = whole), every job in it (not declined, still waiting) is // marked approved BEFORE the authorization is saved — the job rows stop asking "Approve / Decline" and authorization.amount (today's // approved-labor figure, read by reports and old prints) is that estimate's labor. A labor-only store (X4 = labor) keeps today's rule: // the advisor approves each job. Nothing about money changes (pending and approved jobs bill alike). Also used by the e-sign page. const roOkWholeJobs = (roId, shown) => { try { if (shown !== "whole" || !window.DMS || !DMS.setOpApproval) return 0; const ro = (DMS.repairOrders() || []).find(r => r.id === roId); if (!ro) return 0; const declined = DMS.roDeclinedJobIds ? DMS.roDeclinedJobIds(ro) : new Set(); let n = 0; (ro.ops || []).slice().forEach(o => { if (o && (!o.approval || o.approval === "pending") && !declined.has(String(o.id))) { const r = DMS.setOpApproval(roId, o.id, "approved"); if (r && r.ok) n++; } }); return n; } catch (e) { return 0; } }; // RC32 fix (r32-aud M2): the RO's totals for the editor — the store's figures, or (Setup › "Preview an RO": a made-up RO that is not in // the store) the same figures worked out from the RO itself through the one money calculation (DMS.roMoney). const roTotalsFor = (ro) => { const t = ro && DMS.roTotals ? DMS.roTotals(ro.id) : null; if (t) return t; const R2 = (n) => Math.round((+n || 0) * 100) / 100; const m = ro && DMS.roMoney ? DMS.roMoney(ro) : null; const live = ((ro && ro.payments) || []).filter(p => p && !p.voided); const allocated = R2(live.reduce((s, p) => s + (+p.amount || 0), 0)); const fees = R2(live.reduce((s, p) => s + (+p.fee || 0), 0)); const total = m ? m.total : R2((ro && ro.total) || 0); return { total, allocated, fees, balance: R2(total - allocated), customerBalance: m ? m.customerBalance : undefined, collect: R2(allocated + fees), byPayType: (m && m.byPayer) || {}, byMethod: {}, live, money: m }; }; // Approved / Pending / Declined labor of the RO (the same sums as DMS.roEstimateTotals, from the RO itself so a preview RO reads too) const roEstFor = (ro) => { const ops = (ro && ro.ops) || []; const sum = (f) => +ops.filter(f).reduce((s, o) => s + (+o.lab || 0), 0).toFixed(2); return { approved: sum(o => o.approval === "approved"), declined: sum(o => o.approval === "declined"), pending: sum(o => !o.approval || o.approval === "pending") }; }; const roAutoMove = (roId, event) => { try { if (window.DMS && DMS.svcAutoMove) { const r = DMS.svcAutoMove(roId, event); if (r && r.ok && !r.noop && r.msg) window.k2bToast && window.k2bToast(r.msg + " (moved by itself)", "ok", 2600); } } catch (e) {} }; // Carfax from a menu item (same launch as the Carfax button) const roCarfaxOn = () => { const A = window.K2BApps; return !(A && A.isEnabled && !A.isEnabled("carfax")); }; const roCarfaxOpen = (vin, ctx) => { const A = window.K2BApps; if (!A) { window.jbCarfaxOpen && window.jbCarfaxOpen(vin, ctx); return; } A.open("carfax", { vin, ctx }); }; // The technician for a new job (L2): the RO's "New jobs go to" pick, none, or the next tech up (fewest open jobs). const roNewJobTech = (ro, cfg) => { const how = (cfg && cfg.ro && cfg.ro.lists && cfg.ro.lists.newJobTech) || "ro"; if (how === "none") return ""; if (how === "next") { const techs = (DMS.technicians() || []).filter(t => t && t.active !== false); if (!techs.length) return ro.techId || ""; const load = {}; (DMS.repairOrders() || []).filter(r => r.status === "Open").forEach(r => (r.ops || []).forEach(o => { if (o.techId && o.status !== "DONE" && o.approval !== "declined") load[o.techId] = (load[o.techId] || 0) + 1; })); return techs.slice().sort((a, b) => (load[a.id] || 0) - (load[b.id] || 0) || String(a.name).localeCompare(String(b.name)))[0].id; } return ro.techId || ""; }; const ServiceTickets = ({ onClose, go }) => { const [, force] = React.useReducer(n => n + 1, 0); React.useEffect(() => DMS.subscribe(force), []); const cfg = roCfgOf(); const ros = DMS.repairOrders(); const techs = DMS.technicians(); const parts = DMS.parts(); const canSvc = !!(DMS.can && DMS.can("service")); const isTech = !!(DMS.roleIs && DMS.roleIs("Technician")); // RC32 (rset, SPEC 3.2): a read-only sign-in (Technician, Sales, GM) never writes an RO. RC31 materialised an old summary-only RO's // lines on open and saved them; the server now refuses that save from a Technician (filed as a sync issue). Writers keep RC31's // behaviour; the money is the same either way (FopsCalc reads a summary-only RO the way hydrateROLines would). const hydrate = (id) => { if (canSvc && id) DMS.hydrateROLines(id); }; const view = roRoleViewGet(); const who = roMeIds(); const [myView, setMyView] = React.useState(() => roMyViewGet() || { size: "standard", bigText: false, list: "auto", remember: true, start: "store", open: {} }); const startChip = () => { const c = myView && myView.start === "mine" ? "mine" : (view.chip || "open"); return c === "mine" && !(who.techIds.length || who.advisor || isTech) ? "open" : c; }; const [q, setQ] = React.useState(""); const [filter, setFilter] = React.useState(startChip); const [selId, setSelId] = React.useState(null); // RC32: the list is the landing page (no RO opens by itself) const [modal, setModal] = React.useState(null); // {type, ...} const [queueOpen, setQueueOpen] = React.useState(false); const [mode, setMode] = React.useState("list"); // list | board (no RO open) const [railPick, setRailPick] = React.useState(null); // null = My view (P3); "full" | "strip" const [mvOpen, setMvOpen] = React.useState(false); const [techView, setTechView] = React.useState("myjobs"); const [rootRef, box] = useRoBox(); const layout = DMS.svcLayout ? DMS.svcLayout(box.w) : (box.w >= 1000 ? "desk" : box.w >= 600 ? "narrow" : "phone"); const phone = layout === "phone"; const act = roSay; const saveMyView = (patch) => { const r = DMS.svcMyViewSave ? DMS.svcMyViewSave(patch) : { ok: false, view: { ...myView, ...patch } }; setMyView(r.view || { ...myView, ...patch }); if (!r.ok && r.msg) window.k2bToast && window.k2bToast(r.msg, "warn", 3600); return r; }; const resetMyView = () => { const r = DMS.svcMyViewReset ? DMS.svcMyViewReset() : { view: null }; setMyView(r.view || roMyViewGet()); setRailPick(null); setFilter(view.chip === "mine" && !(who.techIds.length || isTech) ? "open" : (view.chip || "open")); }; // Hand-off pointer from Service Reports / Recon tracker / other screens: open that RO once, with the "All" list. React.useEffect(() => { let id = null; try { id = localStorage.getItem("jb_ro_focus"); } catch (e) {} id = id || window.jbROFocus || null; if (!id) return; try { localStorage.removeItem("jb_ro_focus"); } catch (e) {} window.jbROFocus = null; if (ros.find(r => r.id === id)) { setSelId(id); setFilter("all"); setTechView("ro"); hydrate(id); } }, []); // Sales features (H6): when another PC took this RO number at the same moment, ours was re-numbered — // keep the advisor on THEIR RO (the new number), not on the other PC's record under the old one. React.useEffect(() => { const h = (e) => { const d = e && e.detail; if (d && d.collection === "repairOrders") setSelId(cur => (cur === d.from ? d.to : cur)); }; window.addEventListener("k2b-rekeyed", h); return () => window.removeEventListener("k2b-rekeyed", h); }, []); const sel = ros.find(r => r.id === selId) || null; React.useEffect(() => { if (selId) hydrate(selId); }, [selId]); const pick = (id) => { setSelId(id); hydrate(id); setQueueOpen(false); setTechView("ro"); }; // ── queue filtering ── const agedDays = (cfg.ro && cfg.ro.workflow.agedDays) || 7; const matchQ = (r) => { if (!q) return true; const hay = [r.id, r.customer, r.vehicle, r.vin, r.advisor, r.tech, r.payType, r.concern, r.custNum, r.cust && r.cust.phone, r.cust && r.cust.email].join(" ").toLowerCase(); // RC31 (fx31, RC30 acceptance F6): an OLD 10-digit number / a phone in any format finds the RO of its account (DMS.custQueryHit — one reading) return hay.includes(q.toLowerCase()) || (window.DMS && window.DMS.custQueryHit ? window.DMS.custQueryHit(q, r.custNum, [r.cust && r.cust.phone]) : false); }; const FILTERS = [ { k: "mine", label: "Mine", test: (r) => r.status === "Open" && roIsMine(r, who) }, { k: "open", label: "Open", test: (r) => r.status === "Open" }, { k: "aged", label: "Aged", test: (r) => r.status === "Open" && roAgeDays(r) >= agedDays, tone: "red" }, { k: "warranty", label: "Warranty", test: (r) => r.payType === "Warranty" || (r.ops || []).some(o => o.payType === "Warranty") }, { k: "internal", label: "Internal", test: (r) => r.payType === "Internal" || (r.ops || []).some(o => o.payType === "Internal") }, { k: "closed", label: "Closed", test: (r) => r.status === "Closed" }, { k: "void", label: "Void", test: (r) => r.status === "Void" }, // RC36 (rov36): voided / reversed ROs stay in the list (never on Open) { k: "all", label: "All", test: () => true }, ]; const curFilter = FILTERS.find(f => f.k === filter) || FILTERS[1]; const queue = ros.filter(r => matchQ(r) && curFilter.test(r)); const chipCount = (f) => ros.filter(r => matchQ(r) && f.test(r)).length; // ── counters (R10: tiles / one line / off) — "Closed today" counts the STORE's day (the station's local date, not UTC) ── const openROs = ros.filter(r => r.status === "Open"); const wip = openROs.reduce((s, r) => s + ((+r.laborSale || 0) + (+r.partsSale || 0) + (+r.tax || 0)), 0); const aged = openROs.filter(r => roAgeDays(r) >= agedDays).length; const todayKey = new Date().toDateString(); const closedToday = ros.filter(r => r.status === "Closed" && (typeof svcIsToday === "function" ? svcIsToday(r.closed) : (r.closed && new Date(r.closed).toDateString() === todayKey))).length; const countersLine = `${openROs.length} open · ${aged} aged · WIP ${jbm0(wip)} · ${closedToday} closed today`; // ── close RO → ONE server command (RC20 fops); the confirmation is built from the COMMITTED answer only ── const [closing, setClosing] = React.useState(null); const doClose = async (ro) => { if (closing) return { ok: false, msg: "Already closing…" }; setClosing(ro.id); let r; try { r = await DMS.closeRO(ro.id); } finally { setClosing(null); } if (!r.ok) { act(r); return r; } const jeRec = (r.records || []).find(x => x.collection === "journalEntries"); const je = jeRec ? jeRec.record : null; // the office sees the entry; a service login sees the committed invoice const after = DMS.repairOrders().find(x => x.id === ro.id) || ro; const effects = []; (after.partsLines || []).forEach(pl => effects.push({ label: `Parts inventory relieved`, detail: `${pl.qty}× ${pl.part} pulled from stock (acct 242)` })); if (after.flagHours) effects.push({ label: `${after.flagHours}h flagged`, detail: "rolls to payroll & productivity" }); (after.payments || []).filter(p => !p.voided).forEach(p => effects.push({ label: `${DMS.roPayLabel ? DMS.roPayLabel(p.method) : p.method} ${roM$(p.amount)}${p.fee ? " + fee " + roM$(p.fee) : ""}`, detail: `${p.receiptNo} → acct ${DMS.roPayAcct ? DMS.roPayAcct(p, after) : ""}${p.claimNo ? " · claim " + p.claimNo : ""}` })); const inv = r.invoice || {}; if (inv.customerBalanceCents) effects.push({ label: `Customer balance ${roM$(inv.customerBalanceCents / 100)} booked`, detail: "to customer A/R (acct 220) — not cash" }); (inv.receivables || []).filter(x => x.payer !== "Customer").forEach(x => effects.push({ label: `${x.payer} share ${roM$(x.amountCents / 100)} booked`, detail: x.payer === "Warranty" ? "warranty claim receivable (263)" : x.payer === "Internal" ? "charged to the unit (recon)" : "receivable" })); (r.warnings || []).forEach(w => effects.push({ label: "Note", detail: w })); // R14: the posting window (today) or a short note with "Print invoice?" if (cfg.ro && cfg.ro.layout.afterClose === "note") { act(r); if (window.confirm(`${ro.id} closed and posted (${r.jeId || "posted"}). Print the invoice now?`)) window.k2bPrintRO && window.k2bPrintRO(after, after.payType === "Warranty" && !Array.isArray(after.payments) ? "warranty" : "invoice"); return r; } setModal({ type: "gl", je, effects, invoice: inv, jeId: r.jeId, title: `${ro.id} closed — posted to General Ledger (${r.jeId})`, ro: after }); act(r); return r; }; // ── the technician's phone (owner Q2, T2): My jobs, view only — never mounted together with the RO editor ── const techPhone = typeof window.svcTechPhoneMode === "function" && window.svcTechPhoneMode() && typeof window.TechJobs === "function"; if (techPhone && (techView === "myjobs" || !sel)) { const TJ = window.TechJobs; return pick(id)} />; } // ── the "Aa My view" panel + the title bar ── const titleExtra = ( {phone && typeof window.k2bToggleAppMenu === "function" && window.k2bToggleAppMenu()} title="The DMS menu" style={{ minHeight: 32 }}>☰ Menu} {phone && window.JBHelperButton ? : null}{/* RC34 (hlp): the helper button where the phone's top bar would be (CSS shows it only when the top bar steps aside) */} {canSvc && setModal({ type: "new" })} data-ro-new="1" style={{ background: "var(--jb-yellow)", fontWeight: 700, minHeight: phone ? 32 : undefined }}>+ New RO} {!phone && go && go("servicedept")}>📅 Schedule} setMvOpen(o => !o)} title="Your own view of this screen (kept on this computer)" style={{ minHeight: phone ? 32 : undefined }}>Aa{phone ? "" : " My view"} ); const railMode = (() => { const p = railPick || (myView && myView.list) || "auto"; if (p === "full") return "full"; if (p === "strip") return "strip"; return (typeof window !== "undefined" && window.innerWidth < 1300) ? "strip" : "full"; })(); const listHidden = !!sel && layout !== "desk"; const dense = myView && myView.size === "compact"; const zoom = myView && myView.bigText ? 1.12 : 1; const chipsRow = (which, small) => (
{FILTERS.filter(f => !which || which.includes(f.k)).map(f => ( setFilter(f.k)} count={chipCount(f)}>{f.label} ))}
); const searchBox = (ph) => ; const emptyMsg = queue.length === 0 ? (q ? setQ("")} /> :
No repair orders match.
) : null; // the full-width list (no RO open) const fullList = ( ); const rail = ( setRailPick(railMode === "strip" ? "full" : "strip")} onFullList={() => { setSelId(null); setQueueOpen(false); }} overlay={false} /> ); return (
{mvOpen && setMvOpen(false)} phone={phone} />} {/* Body: the full list (no RO open) | slim list + the RO */}
{!sel ? fullList : ( <> {layout === "desk" && rail} {listHidden && queueOpen && (
{ setSelId(null); setQueueOpen(false); }} overlay={true} onHide={() => setQueueOpen(false)} />
setQueueOpen(false)} />
)}
setQueueOpen(true)} techPhone={techPhone} onBackToJobs={() => setTechView("myjobs")} onCloseRO={() => doClose(sel)} onFlag={() => setModal({ type: "flag", ro: sel })} onChangeCustomer={() => setModal({ type: "cust", ro: sel })} />
)}
{modal && modal.type === "flag" && setModal(null)} />} {modal && modal.type === "new" && setModal(null)} onCreated={(id) => { setFilter(f => (f === "closed" ? "open" : f)); pick(id); }} />} {modal && modal.type === "cust" && setModal(null)} />} {modal && modal.type === "gl" && setModal(null)} />}
); }; // ─── "Aa My view" (P1-P6) — this person on this kind of device; kept in the browser only ───── const RoMyViewPanel = ({ view, onSave, onReset, onClose, phone }) => { const v = view || {}; const row = (label, node) =>
{label}{node}
; const seg = (k, opts) => ( {opts.map(([val, lab]) => )} ); return (
My view · this sign-in on this {v.device || "computer"} ✕
{row("Size", seg("size", [["standard", "Standard"], ["compact", "Compact"]]))} {row("Bigger text", seg("bigText", [[false, "Off"], [true, "On"]]))} {row("RO list beside an open RO", seg("list", [["auto", "Automatic"], ["full", "Full"], ["strip", "Thin strip"]]))} {row("Remember which parts of a job I keep open", seg("remember", [[true, "On"], [false, "Off"]]))} {row("Start my RO list on", seg("start", [["store", "Store's choice"], ["mine", "Mine"]]))}
Kept in this browser only — nothing goes to the server. Reset my view
); }; // ─── The full-width RO list (no RO open): rows grouped by step, the old cards, or a board ───── const RO_QCOL = { ro: ["RO", "104px"], cust: ["Customer · vehicle", "minmax(160px, 1.7fr)"], waiting: ["Waiting on / next step", "minmax(140px, 1.4fr)"], tech: ["Technician", "minmax(90px, 0.9fr)"], promised: ["Promised", "minmax(84px, 0.8fr)"], pay: ["Pay type", "minmax(76px, 0.7fr)"], total: ["Total", "96px"] }; const roSortInGroup = (a, b) => { const pa = a.promise ? new Date(a.promise).getTime() : Infinity, pb = b.promise ? new Date(b.promise).getTime() : Infinity; if (pa !== pb) return (isNaN(pa) ? Infinity : pa) - (isNaN(pb) ? Infinity : pb); return String(a.id).localeCompare(String(b.id)); }; const roGrouped = (queue) => { const by = {}; RO_GROUPS.forEach(g => { by[g] = []; }); queue.forEach(r => { const g = roGroupOf(r); (by[g] || by.writeup).push(r); }); RO_GROUPS.forEach(g => by[g].sort(roSortInGroup)); return by; }; const RoQueue = ({ queue, cfg, view, layout, mode, setMode, chipsRow, searchBox, countersLine, tiles, counters, openCount, aged, wip, closedToday, emptyMsg, onPick, dense }) => { const qs = (cfg.ro && cfg.ro.layout.queue) || { style: "rows", groupBySteps: true, columns: ["ro", "cust", "waiting", "tech", "promised", "total"], fold: "never" }; const narrowish = layout !== "desk"; const cols = (qs.columns || []).filter(c => RO_QCOL[c]); const [folded, setFolded] = React.useState({}); const by = roGrouped(queue); const flat = qs.groupBySteps ? null : queue.slice().sort((a, b) => String(b.id).localeCompare(String(a.id))); const row = (r) => (qs.style === "cards" && !narrowish) ? null : onPick(r.id)} dense={dense} />; const groupBlock = (g) => { const list = by[g]; if (!list.length) return null; const meta = RO_GROUP_META[g]; const long = qs.fold === "long" && list.length > 8; const isFolded = folded[g] != null ? folded[g] : false; const shown = long && isFolded ? [] : list; return (
{roGroupLabel(g)} {list.length} {!narrowish && {meta.hint}} {long && setFolded(f => ({ ...f, [g]: !isFolded }))}>{isFolded ? "Show all" : "Fold"}}
{qs.style === "cards" && !narrowish ?
{shown.map(r => onPick(r.id)} />)}
: shown.map(row)}
); }; return (
{searchBox()}
{counters !== "off" && !tiles && {countersLine}} {[["list", "☰ List"], ["board", "▦ Board"]].map(([k, l]) => )}
{tiles && (
)}
{chipsRow(null)} {!narrowish && qs.groupBySteps && Inside each step: soonest promise first}
{emptyMsg} {mode === "board" && queue.length > 0 ? : ( <> {!narrowish && qs.style !== "cards" && queue.length > 0 && (
RO_QCOL[c][1]).join(" "), gap: 10, padding: "7px 14px", position: "sticky", top: 0, zIndex: 2, background: "var(--jb-surface)", borderBottom: "1px solid var(--jb-border)" }}> {cols.map(c => {RO_QCOL[c][0]})}
)} {qs.groupBySteps ? RO_GROUPS.map(groupBlock) : (qs.style === "cards" && !narrowish ?
{flat.map(r => onPick(r.id)} />)}
: flat.map(row))} )}
); }; // One row of the list: still a ); const cell = (c) => { if (c === "ro") return {ro.id}; if (c === "cust") return {ro.customer} {veh}; if (c === "waiting") return {w.text}; if (c === "tech") return {ro.tech || "—"}; if (c === "promised") return {promTxt}{late ? " · late" : ""}; if (c === "pay") return {ro.payType || "Customer"}; if (c === "total") return {jbm(total)}; return null; }; return ( ); }; const RoBoard = ({ by, onPick }) => (
{RO_GROUPS.filter(g => g !== "closed" || by.closed.length).map(g => (
{roGroupLabel(g)} {by[g].length}
{by[g].map(r => onPick(r.id)} />)}
))}
); // ─── Beside an open RO: the slim list (3-line cards) or a thin strip (RO numbers) ───── const RoRail = ({ queue, sel, cfg, mode, chipsRow, searchBox, countersLine, emptyMsg, onPick, onFold, onFullList, overlay, onHide, filter }) => { const by = roGrouped(queue); const railChips = ["open", "mine", "closed", "all"].concat(filter && !["open", "mine", "closed", "all"].includes(filter) ? [filter] : []); if (mode === "strip" && !overlay) return (
☰ List ⇥ {chipsRow(railChips, true)}
{queue.length === 0 &&
None.
} {RO_GROUPS.map(g => by[g].map(r => { const money = DMS.roMoney ? DMS.roMoney(r) : null; const active = r.id === sel; return ( ); }))}
); return (
{searchBox("Search RO#, customer, VIN…")}{overlay && ✕}
{chipsRow(overlay ? null : railChips, true)}
{countersLine}
⤢ Full list {!overlay && ⇤}
{emptyMsg} {RO_GROUPS.map(g => by[g].length ? (
{roGroupLabel(g)}
{by[g].map(r => onPick(r.id)} tall={overlay} />)}
) : null)}
); }; const RoRailCard = ({ ro, active, onClick, tall }) => { const money = DMS.roMoney ? DMS.roMoney(ro) : null; const total = money ? money.total : (ro.total != null ? ro.total : ro.laborSale + ro.partsSale + (ro.tax || 0)); const owes = roOwesNow(ro); const w = roWaitingOn(ro, owes); const late = roPromiseLate(ro); const g = roGroupOf(ro); const veh = String(ro.vehicle || "").replace(/^\d{4}\s+(CHEVROLET|CHEVY|GMC|BUICK|CADILLAC)\s+/i, ""); return ( ); }; // ─── Queue card (today's look: R11 style "cards") ───────────────────────── const RoCard = ({ ro, active, onClick }) => { const days = roAgeDays(ro); const cfg = roCfgOf(); const aged = ro.status === "Open" && days >= ((cfg.ro && cfg.ro.workflow.agedDays) || 7); const money = DMS.roMoney ? DMS.roMoney(ro) : null; // RC20: the same total the editor, the board, the invoice and the journal use const total = money ? money.total : (ro.total != null ? ro.total : ro.laborSale + ro.partsSale + (ro.tax || 0)); return ( ); }; // ─── The 5-step line + the ONE yellow next-step button (SPEC 1.1) ──────── // Every move goes through DMS.svcMoveTo (one stored step at a time, the Advance check, never past the customer's OK). const RoStepBar = ({ ro, cfg, next, ok, phone, readOnlyRole, onNext, nextRef, busy }) => { const g = roGroupOf(ro, cfg); const cur = RO_GROUPS.indexOf(g); const canMove = !readOnlyRole && ro.status !== "Closed"; const back = (k) => { if (!canMove) return; const steps = (DMS.svcSteps ? DMS.svcSteps(cfg && cfg.ro ? { settings: cfg } : undefined) : []).filter(s => s.on && s.group === k); const target = steps.length ? steps[0].stage : null; if (!target) return; if (!window.confirm(`Move ${ro.id} back to "${roGroupLabel(k)}" (${target})?`)) return; roSay(DMS.svcMoveTo(ro.id, target)); }; const okChip = ok.given ? ✓ OK {ok.method === "esign" ? "by e-sign" : ok.method === "phone" ? "by phone" : ok.method ? ok.method : ""} {roTime(ok.at)} : ok.needed ? ⚠ OK needed : null; const btn = next && next.key ? ( ) : null; if (phone) return (
{RO_GROUPS.map((k, i) => )} Step {cur + 1} of 5 · {roGroupLabel(g)} {okChip}
); return (
{RO_GROUPS.map((k, i) => { const done = i < cur, here = i === cur; const body = ( <> {done ? "✓" : i + 1} {roGroupLabel(k)} ); const st = { display: "inline-flex", alignItems: "center", gap: 6, padding: here ? "3px 10px 3px 4px" : "3px 6px 3px 2px", borderRadius: 99, background: here ? "var(--jb-navy)" : "transparent", border: 0, fontFamily: "var(--jb-font)" }; return ( {done && canMove && k !== "closed" ? : {body}} {i < RO_GROUPS.length - 1 && } ); })}
{okChip}
{readOnlyRole ? ⓘ View only : btn}
); }; // RC32 fix (r32-aud L1): on a phone the amount ("· $157.13") is never cut off — the label may wrap after its first part, and each part // stays whole (the button's text is unchanged: "💳 Take payment · $157.13"). const roNextLabelParts = (label, phone) => { const s = String(label || ""); const i = s.lastIndexOf(" · "); if (!phone || i < 0) return s; return <>{s.slice(0, i)} {s.slice(i + 1)}; }; // The yellow button. key "pay" also carries data-ro-take-pay; key "close" carries data-ro-close (one element, never two). const RoNextButton = ({ next, onNext, nextRef, busy, phone }) => { if (!next || !next.key) return null; const grey = next.enabled === false; const extra = next.key === "pay" ? { "data-ro-take-pay": "1" } : next.key === "close" && next.enabled !== false && !next.closeNote ? { "data-ro-close": "1" } : {}; const label = next.key === "pay" ? "💳 " + next.label : next.key === "ok" ? "✎ " + next.label : next.label; return ( {!phone && next.hint && {next.hint}} {!phone && !next.hint && next.key === "pay" && then Close RO & Post} ); }; // ─── More › Detailed step: the 12 stored steps, Back / Advance one step (both through svcMoveTo) ─── const RoStepDetailModal = ({ ro, readOnlyRole, onClose }) => { const steps = DMS.svcSteps ? DMS.svcSteps() : (DMS.RO_STAGES || []).map((s, i) => ({ stage: s, on: true, label: s, index: i })); const idx = Math.max(0, steps.findIndex(s => s.stage === (ro.stage || "Write-Up"))); const closed = ro.status === "Closed"; const hist = ro.stageHist || {}; const prevUsed = (() => { for (let i = idx - 1; i >= 0; i--) if (steps[i].on) return steps[i].stage; return null; })(); const nextAny = idx < steps.length - 1 ? steps[idx + 1].stage : null; return ( {!closed && !readOnlyRole && prevUsed && { const r = roSay(DMS.svcMoveTo(ro.id, prevUsed)); if (r.ok) onClose(); }}>← Back one step} {!closed && !readOnlyRole && nextAny && { const r = roSay(DMS.svcMoveTo(ro.id, nextAny)); if (r.ok) onClose(); }}>Advance one step →} Close }>
Now: {ro.stage || "Write-Up"}{closed ? " · closed" : ""}
{steps.map((s, i) => { const h = hist[s.stage]; const when = h && typeof h === "object" ? (h.skipped ? "skipped (turned off)" : "") : h ? new Date(h).toLocaleString() : ""; return (
{i + 1} {s.label || s.stage}{s.label && s.label !== s.stage ? ({s.stage}) : null}{!s.on ? " · turned off" : ""} {RO_GROUP_META[s.group] ? roGroupLabel(s.group) : ""} {when}
); })}
An RO moves one stored step at a time and never passes "Awaiting Approval" without the customer's OK. Steps turned off in Service Dept › Setup are passed over and marked "skipped".
); }; // "Send to shop" with jobs that have no technician yet: ask once, then move const RoPickTechModal = ({ ro, techs, jobIds, cfg, target, onClose }) => { const [techId, setTechId] = React.useState(roNewJobTech(ro, cfg) || ""); const go = () => { if (techId) jobIds.forEach(id => DMS.updateROJob(ro.id, id, { techId })); const r = roSay(DMS.svcMoveTo(ro.id, target, techId ? { techId } : undefined)); // RC33 (part C, F2): + the RO's own technician when it has none if (r.ok || r.noop) onClose(); }; return ( CancelSend to shop ▸}>
{jobIds.length === 1 ? "One job has" : jobIds.length + " jobs have"} no technician yet. Who does {jobIds.length === 1 ? "it" : "them"}?
); }; // More › Authorization text (the RO's own wording; X2 decides whether an advisor may change it on one RO) const RoAuthTextModal = ({ ro, act, cfg, readOnly, onClose }) => ( {}} /> ); // ─── RO editor (header · step line · tabs · money line; one tree per layout) ── const RoEditor = ({ ro, techs, parts, act, go, layout, cfg, view, dense, myView, saveMyView, closing, listHidden, listCount, onShowList, techPhone, onBackToJobs, onCloseRO, onFlag, onChangeCustomer }) => { const closed = ro.status === "Closed"; const isVoid = ro.status === "Void"; // RC36 (rov36): a VOID / REVERSED RO is history — read-only for every role const canSvc = !!(DMS.can && DMS.can("service")); const readOnlyRole = !canSvc; const readOnly = closed || isVoid || readOnlyRole; const phone = layout === "phone"; const roleKey = roRoleKey(); const sOpt = cfg && cfg.ro ? { settings: cfg } : {}; const g = roGroupOf(ro, cfg); const agedDays = (cfg.ro && cfg.ro.workflow.agedDays) || 7; const days = roAgeDays(ro); const aged = !closed && days >= agedDays; const tot = roTotalsFor(ro); const money = DMS.roMoney ? DMS.roMoney(ro) : null; // RC20 (fops): ONE calculation — the editor, the board, the invoice and the journal agree const owes = roOwesNow(ro); const canClose = !isVoid && !!(DMS.fopsCanCloseRO && DMS.fopsCanCloseRO()); // RC36 (rov36): a VOID RO is never closed const splitPay = Array.isArray(ro.payments); const jobs = ro.ops || []; const ok = isVoid ? Object.assign({}, roOkOf(ro), { needed: false }) : roOkOf(ro); // RC36 (rov36): a VOID RO asks for nothing const next = isVoid ? { key: null } : DMS.svcNextStep ? DMS.svcNextStep(ro, { ...sOpt, owes, readOnly: readOnlyRole, canClose }) : { key: null }; const closeSpot = isVoid ? null : DMS.svcCloseSpot ? DMS.svcCloseSpot(ro, layout, { ...sOpt, owes, readOnly: readOnlyRole, canClose }) : (!readOnly && canClose ? (phone ? "phonebar" : "footer") : null); const checklist = DMS.svcCloseChecklist ? DMS.svcCloseChecklist(ro, sOpt) : { mode: "off", items: [], left: 0, blocking: false }; const headerBtns = (cfg.ro && cfg.ro.layout.headerButtons) || []; const isTechRole = roleKey === "tech"; const mpiAsTab = (view.tabs || []).includes("mpi"); const mpiItems = (cfg.ro && cfg.ro.lists.inspection.items) || RO_MPI_ITEMS; const mpiRed = mpiItems.filter(i => (ro.mpi || {})[i] === "r").length; const defTab = g === "ready" || g === "closed" ? (isTechRole ? "jobs" : "pay") : "jobs"; const [tab, setTab] = React.useState(defTab); const prevG = React.useRef(g); React.useEffect(() => { if (prevG.current !== g) { prevG.current = g; if (g === "ready" && !isTechRole) setTab("pay"); } }, [g]); const [esign, setEsign] = React.useState(false); const [moreOpen, setMoreOpen] = React.useState(false); const [tabMore, setTabMore] = React.useState(false); const [detailsOpen, setDetailsOpen] = React.useState(false); const [sub, setSub] = React.useState(null); // { t: "step" | "auth" | "vin" | "tech" } const [busy, setBusy] = React.useState(false); const [okFlash, setOkFlash] = React.useState(0); const [stdSignal, setStdSignal] = React.useState(0); const [paySignal, setPaySignal] = React.useState(0); const approvalRef = React.useRef(null); const nextRef = React.useRef(null); const printInvoice = () => window.k2bPrintRO && window.k2bPrintRO(DMS.repairOrders().find(r => r.id === ro.id) || ro, ro.payType === "Warranty" && !splitPay ? "warranty" : "invoice"); // the yellow button const onNext = async () => { if (!next || !next.key || busy) return; if (next.key === "invoice") return printInvoice(); if (next.key === "jobs") { setTab("jobs"); setStdSignal(n => n + 1); return; } if (next.key === "ok") { setTab("jobs"); setOkFlash(n => n + 1); setTimeout(() => { try { approvalRef.current && approvalRef.current.scrollIntoView({ block: "start", behavior: "smooth" }); } catch (e) {} }, 60); return; } if (next.key === "shop" || next.key === "start") { // jobs going to the shop without a technician: ask once (SPEC 1.1), then move const need = (next.needTech && next.needTech.length ? next.needTech : roLive(ro).filter(j => !j.techId).map(j => j.id)); const target = next.target || (next.key === "start" ? "In Progress" : null); if (need.length && techs.length) { setSub({ t: "tech", jobIds: need, target }); return; } roSay(DMS.svcMoveTo(ro.id, target)); return; } if (next.key === "ready") { if (next.enabled === false) { const left = (next.left || []).map(id => { const j = jobs.find(x => String(x.id) === String(id)); const n = jobs.indexOf(j) + 1; return j ? `Job ${n} ${j.desc || j.op}` : ""; }).filter(Boolean); window.k2bToast && window.k2bToast(`Not ready yet (${next.hint}). Still to finish: ${left.join(" · ")}`, "warn", 4200); return; } roSay(DMS.svcMoveTo(ro.id, next.target)); return; } if (next.key === "pay") { setTab("pay"); setPaySignal(n => n + 1); return; } if (next.key === "close") { if (next.enabled === false) { window.k2bToast && window.k2bToast(next.hint || "Closing: service advisor / office", "warn", 3000); return; } if (checklist.blocking) { window.k2bToast && window.k2bToast("Finish the \"Before you close\" list first (the store set it to must be done).", "warn", 3600); setTab("pay"); return; } setBusy(true); try { await onCloseRO(); } finally { setBusy(false); } } }; const doCloseFooter = async () => { if (checklist.blocking) { window.k2bToast && window.k2bToast("Finish the \"Before you close\" list first (the store set it to must be done).", "warn", 3600); setTab("pay"); return; } const r = await onCloseRO(); if (r && !r.ok && splitPay && r.code === "payments") setTab("pay"); }; // tabs (R10 order; Accounting a tab for the office, under ⋯ More for everyone else; a technician's Payments under More too) const order = (view.tabs && view.tabs.length ? view.tabs : ["jobs", "cust", "notes", "mpi", "pay"]).filter(k => ["jobs", "cust", "notes", "mpi", "pay"].includes(k)); const label = { jobs: `Jobs (${jobs.length})`, cust: phone ? "Customer" : "Customer & vehicle", notes: "Notes", mpi: phone ? "Inspect" : "Inspection", pay: phone ? "Pay" : `Payments${owes > 0.009 ? " · owes " + roM$(owes) : (splitPay && !money && Math.abs(tot.balance) > 0.009 ? " · " + roM$(tot.balance) + " due" : "")}`, acct: "Accounting", }; const rowTabs = order.filter(k => !(isTechRole && k === "pay") && !(phone && false)); const moreTabs = []; if (isTechRole && order.includes("pay")) moreTabs.push("pay"); if (view.acct === "tab" && !phone) rowTabs.push("acct"); else moreTabs.push("acct"); // header pieces const hdrBtn = (k) => headerBtns.includes(k); const custBtn = !readOnly ? 🔍 Customer… : null; const MORE = [ !readOnly && !hdrBtn("cust") && { k: "cust", label: "Change customer…", run: onChangeCustomer, hook: true }, !hdrBtn("carfax") && roCarfaxOn() && { k: "carfax", label: "Carfax report", run: () => roCarfaxOpen(ro.vin, ro.vehicle), off: !ro.vin }, !hdrBtn("vin") && window.ImpVehicleHistory && String(ro.vin || "").trim().length >= 11 && { k: "vin", label: "History by VIN", run: () => setSub({ t: "vin" }) }, !readOnly && !hdrBtn("flag") && { k: "flag", label: "Flag hours…", run: onFlag }, { k: "step", label: "Detailed step…", run: () => setSub({ t: "step" }) }, { k: "auth", label: "Authorization text…", run: () => setSub({ t: "auth" }) }, { k: "acct", label: "Accounting", run: () => setTab("acct") }, moreTabs.includes("pay") && { k: "pay", label: "Payments", run: () => setTab("pay") }, !isVoid && !closed && window.RoVoidDialog && DMS.roCanVoid && DMS.roCanVoid() && { k: "void", label: "Void RO…", run: () => setSub({ t: "void" }) }, // RC36 (rov36): the Service Manager + the office !isVoid && closed && window.RoVoidDialog && DMS.roCanReverse && DMS.roCanReverse() && { k: "reverse", label: "Reverse closed RO…", run: () => setSub({ t: "reverse" }) }, // RC36 (rov36): the office, a fresh code ].filter(Boolean); const moreMenu = ( setMoreOpen(o => !o)} aria-expanded={moreOpen} style={phone ? { minHeight: 44, minWidth: 44, background: "transparent", color: "#fff", border: "1px solid rgba(255,255,255,.35)" } : undefined}>⋯{phone ? "" : " More"} {moreOpen && ( <> setMoreOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 40 }} /> {MORE.map(m => ( ))} )} ); const vehLine = [ro.vehicle, ro.vin ? "VIN " + ro.vin : "", ro.miles ? Number(ro.miles).toLocaleString() + " mi" : ""].filter(Boolean).join(" · "); const promTxt = roPromiseText(ro), late = roPromiseLate(ro); const chips = ( <> {promTxt && ⏰ Promised {promTxt}{late ? " · late" : ""}} {ro.transport && 🚗 {ro.transport}} {!ro.custNum && !ro.internalStock && !closed && not linked to a customer file} ); const groupBadge = isVoid ? {ro.roVoid && ro.roVoid.kind === "reversed" ? "REVERSED" : "VOID"} : ( {roGroupLabel(g)} ); const agedBadge = aged ? {days}d aged : null; const svcMenu = !closed && !isVoid && window.SvcMenuButton ? : null; // RC28 (svc): the service menu presentation · RC32: "· n due" const printBtn = window.k2bPrintSelectRO && window.k2bPrintSelectRO(ro)} title="Print RO documents">🖨 Print…; // ── tab bodies ── const body = ( <> {isVoid && window.RoVoidBanner && } {tab === "cust" && ( <> {!mpiAsTab && } )} {tab === "jobs" && setEsign(true)} myView={myView} saveMyView={saveMyView} />} {tab === "notes" && } {tab === "mpi" && } {tab === "pay" && } {tab === "acct" && } {esign && window.RoEsignModal && setEsign(false)} />} ); const subModals = ( <> {sub && sub.t === "step" && setSub(null)} />} {sub && sub.t === "auth" && setSub(null)} />} {sub && sub.t === "vin" && window.ImpVehicleHistory && setSub(null)} />} {sub && sub.t === "tech" && setSub(null)} />} {sub && (sub.t === "void" || sub.t === "reverse") && window.RoVoidDialog && setSub(null)} />} ); // ── money line (R13 short / full; "Details ▾" keeps the rest in the page, folded) ── const short = !(cfg.ro && cfg.ro.layout.moneyLine === "full"); const fig = (lab, val, hook, color) => {lab} {val}; const payers = money ? Object.keys(money.byPayer || {}).filter(k => k !== "Customer") : []; const detailFigs = ( <> {fig("Labor", roM$(money ? money.labor : ro.laborSale))} {fig("Parts", roM$(money ? money.parts : ro.partsSale))} {(money ? money.sublet : +ro.subletSale) ? fig("Sublet", roM$(money ? money.sublet : ro.subletSale)) : null} {money && money.fees ? fig("Fees", roM$(money.fees)) : null} {payers.map(k => {k} {roM$(money.byPayer[k])})} {tot.fees ? Card fees {roM$(tot.fees)} : null} ); const paidNow = closed && money ? (money.paid || 0) : (tot.allocated || 0); const owesFig = {phone ? "Owes" : "Customer owes"} 0.009 ? "#B23A2E" : "#0E7A37" }}>{roM$(owes)}; const moneyFigs = (
{!short && detailFigs} Tax {roM$(money ? money.tax : ro.tax)} Total {roM$(money ? money.total : tot.total)} {!phone && Paid {roM$(paidNow)}} {owesFig} {short && } {short && {detailFigs}{phone && Paid {roM$(paidNow)}}}
); const closeBtn = closeSpot === "footer" || closeSpot === "phonebar" ? {closing ? "Closing…" : phone ? "Close RO" : <>Close RO & Post →} : null; const closeNote = !closed && !isVoid && !readOnlyRole && !canClose ? Closing: service advisor / office : null; const takePay = !phone && !closed && !isVoid && !readOnlyRole && next.key !== "pay" ? { setTab("pay"); setPaySignal(n => n + 1); }}>💳 Payment : null; // ─── PHONE (RO box < 600): one column, pinned bottom bar ─── if (phone) { const pbBtn = { minHeight: 48, minWidth: 48, borderRadius: 8, fontSize: 15, fontWeight: 700 }; return (
{techPhone ? ‹ My jobs : ☰ {listCount}} {ro.id} {groupBadge} {techPhone && 👁 View only} {moreMenu}
{ro.customer || "— no customer —"}{ro.custNum ? #{ro.custNum} : null} {agedBadge}
{vehLine}
{chips} {ro.cust && ro.cust.phone ? 📞 {ro.cust.phone} : null}
{printBtn}{svcMenu}{hdrBtn("cust") && custBtn}{hdrBtn("carfax") && }{hdrBtn("vin") && ro.vin && window.ImpVinHistoryButton ? : null}{hdrBtn("flag") && !readOnly && ⏱ Flag hours}
{rowTabs.map(k => ( ))}
{(tab === "acct" || (isTechRole && tab === "pay")) &&
setTab("jobs")}>‹ Back to the RO
} {body}
{moneyFigs}
🖨 {next && next.key && next.key !== "invoice" ? : {closed ? "Closed · posted" : readOnlyRole ? "View only" : ""}} {closeBtn}
{subModals}
); } // ─── DESK / NARROW ─── return (
{/* Header: line 1 RO · step · customer · buttons; line 2 vehicle · VIN · miles (never cut off) + promised / transport */}
{listHidden && ☰ RO list ({listCount})} {ro.id} {groupBadge}{agedBadge} {ro.customer || "— no customer —"}{ro.custNum ? #{ro.custNum} : null} {hdrBtn("cust") && custBtn} {printBtn} {hdrBtn("carfax") && } {hdrBtn("vin") && ro.vin && window.ImpVinHistoryButton ? : null}{/* RC26 (inv): the vehicle's whole history by VIN */} {hdrBtn("flag") && !readOnly && ⏱ Flag hours} {svcMenu} {moreMenu}
{vehLine} {chips}
{/* Tabs */}
{rowTabs.map(k => ( ))} {moreTabs.length > 0 && ( {moreTabs.map(k => )} )}
{/* Scrolling body */}
{body}
{/* Money line + the footer buttons */}
{moneyFigs}
🖨 Invoice {closed ? ( setTab("acct")}>📒 GL entries ) : !readOnlyRole && ( <> {takePay} {closeBtn} {closeNote} )}
{subModals}
); }; // ─── Authorization wording (prints on the RO; the customer's e-sign page shows the RO's own text) ────── // X2: the store's wording (blank = the standard RO_AUTH_DISCLAIMER); "per-RO edit" lets an advisor change it on one RO. const roStoreAuthText = (cfg) => (cfg && cfg.ro && cfg.ro.texts.authText && cfg.ro.texts.authText.text) || window.RO_AUTH_DISCLAIMER || ""; const RoDisclosureCard = ({ ro, act, cfg, readOnly, open: openProp, onToggle }) => { const closed = ro.status === "Closed"; const [openLocal, setOpenLocal] = React.useState(false); const open = openProp != null ? openProp : openLocal; const setOpen = (fn) => { const v = typeof fn === "function" ? fn(open) : fn; onToggle ? onToggle(v) : setOpenLocal(v); }; const storeText = roStoreAuthText(cfg || roCfgOf()); const perRo = !(cfg && cfg.ro && cfg.ro.texts.authText && cfg.ro.texts.authText.perRoEdit === false); const val = ro.disclosure != null ? ro.disclosure : storeText; const canEdit = !closed && !readOnly && perRo; return (
setOpen(o => !o)} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 14px", cursor: "pointer", background: "var(--jb-surface-2)", minHeight: 24 }}> Customer Disclosure (prints on RO)
{ro.disclosure != null && ro.disclosure !== storeText && customized} {open ? "▴" : "▾"}
{open && (