/* © 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 — Classic Reports (route "classicreports"). // The reports the first store ran in its previous DMS, rebuilt from live K2B data and // printed in the same layout, so the office reads them the same way: // 1 Deal Recap 2 Deal Posting Worksheet 3 Journal Document // 4 F&I → GL Setup 5 Chart of Accounts 6 Aged Schedules (Schedule 0) // 7 GL Account Detail 8 Account History 9 GM Operating Report p.1 // 10 Vehicle Master Report 11 Trial Balance (live, old print layout) // Every report builds one HTML string used for both the screen and the printout. // Only the Deal Recap stores anything: its recap-only inputs (holdback, adds, // special finance discount, buy rate, commission lines…) on deal.recap. // SEARCH MODE: every place that used to be a drop-down / datalist is now a type-ahead // finder (CrFinder) over a per-data-version search index; "Find anything" at the top // of the screen searches deals, journal documents, accounts, vehicles and customers. // D/C: the old Chart of Accounts print has a "D/C" column of Y/N flags (some "Y 50"). // K2B shows it as "D/C flag (as printed)" - it is NOT the normal debit/credit balance. /* ── shared helpers ───────────────────────────────────────────────────────── */ const crD = () => window.DMS || {}; const crNum = (v) => { const n = +v; return Number.isFinite(n) ? n : 0; }; const crR2 = (n) => Math.round(crNum(n) * 100) / 100; const crAmt = (n, blankZero) => { let v = crR2(n); if (Object.is(v, -0) || Math.abs(v) < 0.005) v = 0; if (blankZero && !v) return "N/A"; return v.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }; // also escapes quotes: values are used inside HTML attributes too (data-je, data-tb-acct, title) const crEsc = (s) => String(s == null ? "" : s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); const crPad = (s, n) => { const x = String(s == null ? "" : s); return x.length >= n ? x.slice(0, n) : x + " ".repeat(n - x.length); }; const crLpad = (s, n) => { const x = String(s == null ? "" : s); return x.length >= n ? x.slice(-n) : " ".repeat(n - x.length) + x; }; const crToday = () => { const d = new Date(); return `${d.getMonth() + 1}-${d.getDate()}-${d.getFullYear()}`; }; const crTime = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" }).toLowerCase(); // "MM/DD/YY" | ISO → Date const crDate = (s) => { if (!s) return null; if (s instanceof Date) return s; const m = /^(\d{1,2})[\/-](\d{1,2})[\/-](\d{2,4})/.exec(String(s)); if (m) { const y = m[3].length === 2 ? 2000 + +m[3] : +m[3]; return new Date(y, +m[1] - 1, +m[2]); } const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(s)); if (iso) return new Date(+iso[1], +iso[2] - 1, +iso[3]); const t = Date.parse(s); return Number.isFinite(t) ? new Date(t) : null; }; const crMDY = (s) => { const d = crDate(s); return d ? `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}-${String(d.getFullYear()).slice(-2)}` : " - - "; }; const crDays = (s, asOf) => { const d = crDate(s); return d ? Math.max(0, Math.floor(((asOf || new Date()) - d) / 86400000)) : 0; }; const crPeriod = (j) => j.period || (() => { const d = crDate(j.date); return d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}` : ""; })(); const crMonthLbl = (p) => p ? `${+p.slice(5, 7)}-${p.slice(2, 4)}` : ""; const crJEs = () => (crD().journalEntries ? crD().journalEntries() : []) || []; const crAcctName = (a) => (crD().acctName ? crD().acctName(a) : "") || ""; const crInv = () => (crD().inventory ? crD().inventory() : []) || []; const crUnit = (stock) => (crD()._unit ? crD()._unit(stock) : null); const crDeals = () => (crD().deals ? crD().deals() : []) || []; // The control a GL line belongs to: the line's own, else the entry's stock / RO / deal / ref. const crControl = (j, l) => String((l && (l.control || l.stock)) || j.stock || j.ro || j.dealId || j.ref || "").toUpperCase(); const crHead = (title, sub) => `
${crToday()}
K2B
${K2BPrint.esc(K2BPrint.party.print())}
${crEsc(title)}${sub ? "
" + crEsc(sub) : ""}
${crTime()}
PAGE: 1
`; const CR_CSS = `.cr-pre{font-family:"Courier New",ui-monospace,monospace;font-size:12px;line-height:1.35;white-space:pre;margin:0;color:#111} .cr-head{display:grid;grid-template-columns:1fr 2fr 1fr;font-family:"Courier New",monospace;font-size:12px;margin-bottom:10px} .cr-t{border-collapse:collapse;font-family:"Courier New",monospace;font-size:11.5px;width:100%} .cr-t th{text-align:left;border-bottom:1px dashed #555;padding:2px 6px;font-weight:600;white-space:nowrap} .cr-t td{padding:1px 6px;white-space:nowrap;vertical-align:top} .cr-t td.n,.cr-t th.n{text-align:right}.cr-t tr.sub td{border-top:1px dashed #888;font-weight:700} .cr-t tr.tot td{border-top:2px solid #333;font-weight:700}.cr-flag{color:#9A3412;font-weight:700} .cr-note{font-family:system-ui,sans-serif;font-size:11px;color:#555;margin-top:8px} .cr-red{color:#C00000;font-weight:700}.cr-t td.dim{color:#8A8F98} .cr-banner{font-family:system-ui,sans-serif;font-size:12px;font-weight:700;color:#8A1C00;background:#FFF1E6;border:1px solid #F0B48A;border-radius:4px;padding:5px 9px;margin:0 0 8px} .cr-t tr.gap td{padding:0;height:7px}`; const crPrint = (title, html, landscape) => { const body = `${html}`; if (window.k2bPrint) window.k2bPrint(body, { title, formKey: "classic-" + title.toLowerCase().replace(/\W+/g, "-") }); }; /* Chart of accounts: the store's chart (server) over its built-in chart printout (a store whose chart is built in only - RC35). glAccounts records may carry the old-DMS print fields (import "glChart"): alt = Alter number · dc = D/C flag AS PRINTED (Y / N / "Y 50") · costAcct · invAcct When a field is absent we fall back to what K2B derived before: dc from the standard chart's normal side (shown grey, flagged dcDerived), cost / inv from the posting-map and unit links. */ // The old print's TYPE is one letter (A L S C E I). The chart import stores K2B's word for it (Sales, Asset ...): show the letter again. const crTypeLetter = (t) => { const s = String(t == null ? "" : t).trim(); const w = { asset: "A", liability: "L", equity: "L", sales: "S", sale: "S", cost: "C", expense: "E", income: "I" }[s.toLowerCase()]; return w || s; }; const crCOA = () => { const m = {}; ((window.K2BStore && !window.K2BStore.builtinChart()) ? [] : (window.K2B_CHART_BUILTIN || [])).forEach(([acct, name, type, nb, group]) => { m[acct] = { acct, name, type, dc: nb === "Cr" ? "C" : "D", dcDerived: true, group }; }); ((crD().glAccounts && crD().glAccounts()) || []).forEach(a => { if (!a) return; const acct = String(a.acct || a.account || a.no || ""); if (!acct) return; const prev = m[acct] || {}; const hasDc = a.dc != null && String(a.dc).trim() !== ""; m[acct] = { ...prev, acct, name: a.name || a.desc || a.description || prev.name || "", type: crTypeLetter(a.type || prev.type || ""), dc: hasDc ? String(a.dc).trim() : (prev.dc || (a.normal === "Cr" ? "C" : "D")), dcDerived: hasDc ? false : (prev.dcDerived !== false), alt: a.alt || a.alter || prev.alt || "", cost: a.costAcct ? String(a.costAcct) : (prev.cost || ""), inv: a.invAcct ? String(a.invAcct) : (prev.inv || "") }; }); // sale → cost → inventory links: K2B posting map pairs + every unit's own accounts (only fill what the record did not say) const A = crD()._ACCT || {}; const link = (s, c, i) => { if (s && m[s]) { m[s].cost = m[s].cost || c; m[s].inv = m[s].inv || i; } else if (s) m[s] = { acct: s, name: crAcctName(s), type: "S", cost: c, inv: i }; }; link(A.saleNewCar, A.costNewCar, A.invNewCar); link(A.saleNewTruck, A.costNewTruck, A.invNewTruck); link(A.saleUsedCar, A.costUsedCar, A.invUsedCar); link(A.saleUsedTruck, A.costUsedTruck, A.invUsedTruck); link(A.custLabor, A.costLabor, ""); link(A.partsCarRO, A.costPartsRO, A.invParts); link(A.partsCounter, A.costPartsCounter, A.invParts); crInv().forEach(u => { if (u.saleAcct) link(u.saleAcct, u.costAcct, u.invAcct); }); return m; }; /* ═══ 1. DEAL RECAP ═══════════════════════════════════════════════════════ */ const crPayPlan = () => (crD().payPlanConfig ? crD().payPlanConfig() : { usedPct: 0.25, newPct: 0.20, miniFloor: 150 }); const crCommDefaultsKey = "k2b-recap-comm-defaults"; const crCommDefaults = () => { try { return JSON.parse(localStorage.getItem(crCommDefaultsKey) || "{}"); } catch (e) { return {}; } }; const crSaveCommDefault = (name, row) => { try { const all = crCommDefaults(); all[String(name).toUpperCase()] = { pct: row.pct, flat: row.flat, mini: row.mini }; localStorage.setItem(crCommDefaultsKey, JSON.stringify(all)); } catch (e) {} }; // Everything the recap shows, from the deal + its unit + saved recap inputs. const crRecapModel = (d) => { const r = d.recap || {}; const u = crUnit(d.stock) || {}; const isNew = /^n/i.test(String(d.type || u.type || "")); const basis = crD().costBasis ? crD().costBasis(d.stock) : { found: false }; const invoice = r.invoicePrice != null ? crNum(r.invoicePrice) : crNum(u.invoiceCost || u.cost || d.cost); const holdback = crNum(r.holdback); const pack = r.pack != null ? crNum(r.pack) : crNum(u.pack); // RC28 (fx, gate 2b): the deal's cost lines (Profit panel) are part of the recap's cost — front lines as adds, back lines off the F&I — // exactly as the desk, the posting and the commission take them const caR = window.DealCalc && Array.isArray(d.costAdds) && d.costAdds.length ? window.DealCalc.costAddsOf(d) : null; const adds = (r.adds || []).filter(a => a && !a.costLine && (a.label || a.amount)).map(a => ({ label: String(a.label || ""), amount: crNum(a.amount) })) .concat(caR ? caR.lines.filter(l => l.cost && l.side === "front" && l.amountCents > 0).map(l => ({ label: (l.label + (l.desc ? " — " + l.desc : "")).toUpperCase().slice(0, 30), amount: l.amountCents / 100, costLine: true })) : []); const sfd = crNum(r.sfd); const glRepairs = r.glRepairs != null ? crNum(r.glRepairs) : crNum((basis.found ? basis.reconTotal : 0) + crNum(u.recon)); const totalRepairs = crNum(r.totalRepairs); const totalCost = crR2(invoice + holdback + pack + adds.reduce((s, a) => s + a.amount, 0) + sfd + glRepairs + totalRepairs); const sale = crNum(d.salePrice); const cash = crNum(d.cashDown); const cashStated = r.cashStated != null ? crNum(r.cashStated) : cash; const t1acv = crNum(d.tradeACV != null ? d.tradeACV : d.tradeAcv); const t1allow = crNum(d.tradeAllow); const t2acv = crNum(r.trade2Acv), t2allow = crNum(r.trade2Allow); const over1 = (t1allow || t1acv) ? crR2(t1acv - t1allow) : 0; const over2 = (t2allow || t2acv) ? crR2(t2acv - t2allow) : 0; const front1 = crR2(sale - totalCost); const gross = crR2(front1 + over1 + over2); const contract = crNum(d.apr), buy = r.buyRate != null ? crNum(r.buyRate) : contract; const reserve = r.reserve != null ? crNum(r.reserve) : crNum(d.reserve); const cl = { prem: crNum(r.clPrem), cost: crNum(r.clCost) }, ah = { prem: crNum(r.ahPrem), cost: crNum(r.ahCost) }; const sc = { prem: crNum(d.vscSale), cost: crNum(d.vscCost) }, gap = { prem: crNum(d.gapSale), cost: crNum(d.gapCost) }; const miscFi = crNum(r.miscFi); const fi = crR2(reserve + (cl.prem - cl.cost) + (ah.prem - ah.cost) + (sc.prem - sc.cost) + (gap.prem - gap.cost) + miscFi - (caR ? caR.backCents / 100 : 0)); // RC28 (fx): − the BACK cost lines const holdbackProfit = crNum(r.holdbackProfit != null ? r.holdbackProfit : holdback); const miscDealer = crNum(r.miscDealer); const totalDealer = crR2(gross + fi + holdbackProfit + pack + miscDealer); const pp = crPayPlan(); const defs = crCommDefaults(); const spName = String(d.salesperson || "").toUpperCase(); const comm = (r.comm && r.comm.length ? r.comm : [ { name: spName, base: "front", pct: ((defs[spName] || {}).pct != null ? defs[spName].pct : ((isNew ? pp.newPct : pp.usedPct) * 100)), flat: (defs[spName] || {}).flat || 0, mini: (defs[spName] || {}).mini != null ? defs[spName].mini : pp.miniFloor }, { name: "", base: "front", pct: 0, flat: 0, mini: 0 }, { name: String(r.fiMgr || d.fiManager || "").toUpperCase(), base: "fi", pct: r.fiMgr && defs[String(r.fiMgr).toUpperCase()] ? defs[String(r.fiMgr).toUpperCase()].pct : 0, flat: 0, mini: 0 }, ]).map(c => { const base = c.base === "fi" ? fi : gross; const raw = crR2(base * crNum(c.pct) / 100 + crNum(c.flat)); const amount = c.name ? crR2(Math.max(crNum(c.mini), raw)) : 0; return { ...c, baseAmt: base, raw, amount, mini: crNum(c.mini) }; }); const commTotal = crR2(comm.reduce((s, c) => s + c.amount, 0)); const netNet = crR2(totalDealer - commTotal); const term = crNum(d.term), pay = crNum(d.payment), fin = crNum(d.amtFinanced); const finCharge = term && pay && fin ? crR2(pay * term - fin) : 0; const t2 = { stock: r.trade2Stock || "", desc: r.trade2Desc || "", vin: r.trade2Vin || "", miles: r.trade2Miles || "" }; // reserve estimate for reference: finance-charge difference between contract and buy rate let reserveEst = 0; if (fin && term && contract > buy) { const p = (rate) => { const i = rate / 1200; return i ? fin * i / (1 - Math.pow(1 + i, -term)) : fin / term; }; reserveEst = crR2((p(contract) - p(buy)) * (1 - Math.pow(1 + buy / 1200, -term)) / (buy / 1200 || 1)); } return { d, r, u, isNew, invoice, holdback, pack, adds, sfd, glRepairs, totalRepairs, totalCost, sale, cash, cashStated, t1acv, t1allow, t2acv, t2allow, over1, over2, front1, gross, contract, buy, reserve, reserveEst, cl, ah, sc, gap, miscFi, fi, holdbackProfit, miscDealer, totalDealer, comm, commTotal, netNet, term, pay, fin, finCharge, t2 }; }; const crRecapHtml = (m) => { const d = m.d, u = m.u; const cob = d.cobuyer || (d.creditApp && d.creditApp.coBuyer && [d.creditApp.coBuyer.first, d.creditApp.coBuyer.last].filter(Boolean).join(" ")) || ""; const sold = [u.yr || "", m.isNew ? "N" : "U", (u.mk || "").toUpperCase(), (u.mdl || d.vehicle || "").toUpperCase(), (u.color || "").toUpperCase()].filter(Boolean).join(" "); const L = []; const eq = "=".repeat(78); const row = (label, a, b, c) => L.push(crPad(label, 28) + ": " + crLpad(a, 10) + (b != null ? b : "") + (c != null ? c : "")); const nn = (v) => (crNum(v) ? crAmt(v) : "N/A"); L.push(`Deal No.: ${crPad(d.id, 9)} Inv #: ${crPad(d.invoiceNo || d.invNo || d.id, 9)} Date: ${crMDY(d.delivered || d.date || d.created)} F&I -${(d.lender || "CASH").toUpperCase()}`); L.push(`Buyer...: ${crPad((d.buyer || "").toUpperCase(), 44)} -${(d.salesperson || "").toUpperCase()}`); L.push(`Cobuyer.: ${cob.toUpperCase()}`); L.push(`Address.: ${[d.street, d.city].filter(Boolean).join(", ").toUpperCase()}${d.state ? ", " + d.state.toUpperCase() : ""} ${d.postal || ""}`); L.push(`Phone...: ${d.phone || ""}`); L.push(` --STOCK--- ------------DESCRIPTION---------- -------VIN------- -MILES`); L.push(`Sold...: ${crPad(d.stock || "", 10)} ${crPad(sold, 34)} ${crPad(u.vin || d.vin || "", 17)} ${u.miles || ""}`); L.push(`Trade 1: ${crPad(d.tradeStock || "", 10)} ${crPad((d.tradeDesc || d.tradeYrMkMdl || "").toUpperCase(), 34)} ${crPad((d.tradeVin || "").toUpperCase(), 17)} ${d.tradeMiles || (d.tradeAllow ? "" : "0")}`); L.push(`Trade 2: ${crPad(m.t2.stock || "0", 10)} ${crPad(String(m.t2.desc).toUpperCase(), 34)} ${crPad(String(m.t2.vin).toUpperCase(), 17)} ${m.t2.miles || "0"}`); L.push(eq); row("Invoice Price..............", crAmt(m.invoice)); row("Hold Back and Advertising..", nn(m.holdback)); row("Pack Lot...................", nn(m.pack)); m.adds.forEach(a => row(a.label.toUpperCase(), crAmt(a.amount))); row("Special Finance Discount" + (d.lender ? "(" + d.lender.slice(0, 5).toUpperCase() + ")" : ""), nn(m.sfd)); row("GL Repairs and Adjustments.", nn(m.glRepairs)); row("Total Repairs..............", nn(m.totalRepairs)); row("Total Cost.................", crAmt(m.totalCost)); L.push(eq); const two = (label, a, mid, b, res) => L.push(crPad(label, 21) + ": " + crLpad(a, 9) + " -" + crPad(mid, 10) + ": " + crLpad(b, 9) + " = " + crLpad(res, 9)); two("Selling Price........", crAmt(m.sale), "Total Cost", crAmt(m.totalCost), crAmt(m.front1)); two("Cash Received........", nn(m.cash), "Stated....", nn(m.cashStated), m.cash !== m.cashStated ? crAmt(m.cash - m.cashStated) : "N/A"); two("Cash Value Of Trade 1", nn(m.t1acv), "Allowance.", nn(m.t1allow), m.over1 ? crAmt(m.over1) : "N/A"); two("Cash Value Of Trade 2", nn(m.t2acv), "Allowance.", nn(m.t2allow), m.over2 ? crAmt(m.over2) : "N/A"); L.push(crPad("Gross Profit" + ".".repeat(58), 70) + ">" + crLpad(crAmt(m.gross), 10)); L.push(""); two("Contract Rate........", m.contract ? m.contract.toFixed(5) : "N/A", "Buy Rate..", m.buy ? m.buy.toFixed(5) : "N/A", nn(m.reserve)); two("Credit Life Premium..", nn(m.cl.prem), "Cost......", nn(m.cl.cost), m.cl.prem ? crAmt(m.cl.prem - m.cl.cost) : "N/A"); two("Disability Premium...", nn(m.ah.prem), "Cost......", nn(m.ah.cost), m.ah.prem ? crAmt(m.ah.prem - m.ah.cost) : "N/A"); two("Service Contract.....", nn(m.sc.prem), "Cost......", nn(m.sc.cost), m.sc.prem ? crAmt(m.sc.prem - m.sc.cost) : "N/A"); two("PDI/Gap Contract.....", nn(m.gap.prem), "Cost......", nn(m.gap.cost), m.gap.prem ? crAmt(m.gap.prem - m.gap.cost) : "N/A"); L.push(crPad("Miscellaneous F&I Profit" + ".".repeat(40), 58) + "> " + crLpad(nn(m.miscFi), 9)); L.push(crPad("F & I Profit" + ".".repeat(58), 70) + ">" + crLpad(nn(m.fi), 10)); L.push(""); L.push(crPad("Hold Back" + ".".repeat(60), 70) + ">" + crLpad(nn(m.holdbackProfit), 10)); L.push(crPad("Pack Lot" + ".".repeat(60), 70) + ">" + crLpad(nn(m.pack), 10)); L.push(crPad("Miscellaneous Dealer Profit" + ".".repeat(40), 70) + ">" + crLpad(nn(m.miscDealer), 10)); L.push(" ".repeat(71) + "---------"); L.push(crPad("Total Dealer Profit" + ".".repeat(50), 70) + ">" + crLpad(crAmt(m.totalDealer), 10)); m.comm.forEach(c => { const nm = c.name ? crPad(c.name + ".".repeat(30), 28) : ".".repeat(28); L.push(`${nm} = ${crLpad(c.name ? crAmt(c.baseAmt) : "N/A", 9)} X ${crLpad(crNum(c.pct).toFixed(1), 4)} + ${crLpad(c.flat ? crAmt(c.flat) : "N/A", 7)}.....>${crLpad(c.name ? crAmt(c.amount) : "N/A", 10)}${c.name && c.amount > c.raw ? " (mini)" : ""}`); }); L.push(crPad("Net/Net" + ".".repeat(62), 70) + ">" + crLpad(crAmt(m.netNet), 10)); L.push(""); L.push(""); if (m.fin) { L.push(`AMOUNT FINANCED ${crLpad(crAmt(m.fin), 9)}`); L.push(`FINANCE CHARGE ${crLpad(m.finCharge ? crAmt(m.finCharge) : "N/A", 9)}`); L.push(`TERMS ${m.term || ""} @ ${crAmt(m.pay)}`); } if (crNum(d.tradePayoff)) { L.push(""); L.push(` ${(d.tradeLender || "").toUpperCase()}`); L.push(`PAYOFF 1 ${crAmt(d.tradePayoff)}`); } return `
${crEsc(L.join("\n"))}
`; }; /* ═══ 2. DEAL POSTING WORKSHEET ═══════════════════════════════════════════ */ const crDealJEs = (d) => crJEs().filter(j => j.dealId === d.id || String(j.ref || "") === d.id || String(j.id || "").startsWith(d.id) || String(j.id || "").startsWith("DL-" + d.id)); const crPostingHtml = (d) => { const jes = crDealJEs(d); const agg = {}; jes.forEach(j => (j.lines || []).forEach(l => { const k = String(l.acct) + "|" + crControl(j, l); const a = agg[k] || (agg[k] = { acct: String(l.acct), control: crControl(j, l), label: crAcctName(l.acct) || l.desc || "", amt: 0 }); a.amt += crNum(l.dr) - crNum(l.cr); })); const rows = Object.values(agg).filter(a => Math.abs(a.amt) > 0.004); const tot = rows.reduce((s, a) => s + a.amt, 0); const u = crUnit(d.stock) || {}; const head = `Deal No.: ${d.id} Date: ${crMDY(d.delivered || d.date || d.created)} F&I -${(d.lender || "CASH").toUpperCase()}\nBuyer...: ${(d.buyer || "").toUpperCase()} -${(d.salesperson || "").toUpperCase()}\nSold...: ${d.stock || ""} ${[u.yr, u.mk, u.mdl].filter(Boolean).join(" ").toUpperCase()} ${u.vin || ""}`; if (!rows.length) return `
${crEsc(head)}
This deal has not been posted to accounting yet — nothing to show. Post it from Cash Deal / Finance Deal.
`; return `
${crEsc(head)}
${rows.map(a => ``).join("")}
ACCTINGACCT#NOTES
${crEsc(String(a.label).toUpperCase().slice(0, 34))}${crAmt(a.amt)}${crEsc(a.acct)}${crEsc(a.control)}
Debits less credits (must be 0.00)${crAmt(tot)}${jes.length} journal entr${jes.length === 1 ? "y" : "ies"}: ${crEsc(jes.map(j => j.id).join(", "))}
Positive = debit, negative = credit (same sign convention as the old worksheet's journal print).
`; }; /* ═══ 3. JOURNAL DOCUMENT PRINT ═══════════════════════════════════════════ */ const crJournalHtml = (j) => { if (!j) return `
Pick a document.
`; const u = j.stock ? crUnit(j.stock) : null; const ctlName = u ? [String(u.yr || "").slice(-2), u.mk, u.mdl, (u.vin || "").slice(-8)].filter(Boolean).join(" ").toUpperCase() : ""; const tot = (j.lines || []).reduce((s, l) => s + crNum(l.dr) - crNum(l.cr), 0); return crHead("DOCUMENT PRINT") + `
${crEsc(`JOURNAL  ${j.jrn || ""}\nDOCUMENT ${j.ref || j.dealId || j.id}\nDESC     ${(j.desc || "").toUpperCase()}\nDATE     ${crMDY(j.date)}   PERIOD ${crMonthLbl(crPeriod(j))}   ENTRY ${j.id}${j.reversedBy ? "   REVERSED BY " + j.reversedBy : ""}`)}
${(j.lines || []).map(l => ``).join("")}
ACCOUNTNAMECONTROLNAMEENTRY DESCRIPTIONAMOUNT
${crEsc(l.acct)}${crEsc(crAcctName(l.acct).toUpperCase().slice(0, 22))}${crEsc(crControl(j, l))}${crEsc(ctlName.slice(0, 22))}${crEsc((l.desc || j.desc || "").toUpperCase().slice(0, 30))}${crAmt(crNum(l.dr) - crNum(l.cr))}
TOTAL (debits − credits)${crAmt(tot)}
`; }; /* ═══ 4. F&I → GL INTERFACE SETUP (old vs K2B) ════════════════════════════ */ // Old-DMS mapping as printed on the first store's FTGINT screen. const CR_OLD_FI = [ ["Sales Journal", "", "", "Journal 10"], ["Sales Tax", "324A", "salesTaxPay"], ["Convenience Fees", "324A", ""], ["License", "905D", "titlePay"], ["Title", "905D", "titlePay"], ["Registration", "905D", "titlePay"], ["Filing Fees", "905D", "titlePay"], ["Documentary Fee", "905A", "docFee"], ["Cash Down", "225A", "cashOnHand"], ["Rebate", "261", "factoryRec"], ["Deposit/D-Down", "225A", "custDeposits"], ["Pay Off", "303", "payoffPayable"], ["Dealer Transfer", "310", "floorNewGM"], ["Misc Dealer Inc", "806", ""], ["Finance Reserve New", "806", "fiNew"], ["Finance Reserve Used", "808", "fiUsed"], ["Credit Life New", "807", ""], ["Credit Life Used", "809", ""], ["Credit A&H New", "807", ""], ["Credit A&H Used", "809", ""], ["Warranty New", "444", ""], ["Warranty Used", "455", "vscPayable"], ["GAP (sale)", "455A", "gapPayable"], ["Commissions Payable", "", "comPayable"], ["F&I Expense New", "806", ""], ]; const crFiSetupHtml = () => { const A = crD()._ACCT || {}; const rows = CR_OLD_FI.map(([label, old, key, note]) => { const k2b = key ? (A[key] || "") : ""; const same = old && k2b && old === k2b; const status = note ? note : !k2b ? (old ? "not set up in K2B" : "") : same ? "same" : "different"; return `${crEsc(label)}${crEsc(old)}${crEsc(old ? crAcctName(old) : "")}${crEsc(k2b)}${crEsc(k2b ? crAcctName(k2b) : "")}${crEsc(status)}`; }).join(""); return crHead("F&I TO GL INTERFACE — OLD DMS vs K2B") + `${rows}
Deal itemOld acctOld nameK2B acctK2B nameStatus
Old values are from the old DMS setup screen (control by Stock). K2B accounts come from Settings › GL Flow — change them there. Sale, cost and inventory accounts follow each vehicle's own accounts (see Chart of Accounts).
`; }; /* ═══ 5. CHART OF ACCOUNTS ════════════════════════════════════════════════ */ // Filter: every word must match (AND) — an account number PREFIX, or a word / fragment of the name, alter number or type. const crCoaHtml = (filter) => { const m = crCOA(); const toks = crParseTokens(filter); const all = Object.values(m); const rows = all.filter(a => { if (!toks.length) return true; const acct = String(a.acct).toLowerCase(); const hay = crHay([a.name, a.alt, a.type, a.cost, a.inv], []); return toks.every(t => acct.startsWith(t.s) || acct.startsWith(t.h) || crHasTok(hay, t)); }).sort((a, b) => a.acct.localeCompare(b.acct, undefined, { numeric: true })); const anyDerived = rows.some(a => a.dc && a.dcDerived); return crHead("CHART OF ACCOUNTS", toks.length ? `filtered: ${rows.length} of ${all.length}` : "") + ` ${rows.map(a => `${crEsc(a.dc || "")}`).join("")}
ACCT NUMBERALTER NUMBERDESCRIPTIONTYPED/C flag (as printed)COSTINVENT
${crEsc(a.acct)}${crEsc(a.alt || "")}${crEsc(String(a.name || "").toUpperCase())}${crEsc(a.type || "")}${crEsc(a.cost || "")}${crEsc(a.inv || "")}
${rows.length}${toks.length ? " of " + all.length : ""} accounts. TYPE: A asset · L liability · S sale · C cost · E expense · I income. D/C flag (as printed) = the Y / N flag in the old chart's D/C column (a few print as "Y 50"); K2B shows it exactly as printed and does not treat it as a debit or credit side. ${anyDerived ? "Grey entries were not read from the old print (no D/C flag on file for that account) - they are only a K2B estimate. " : ""}COST / INVENT = the cost-of-sale and inventory accounts a sale account relieves.
`; }; /* ═══ 6. AGED SCHEDULES (Schedule 0) ═════════════════════════════════════ */ const crScheduleData = (acct, asOf) => { const by = {}; crJEs().forEach(j => { const dt = crDate(j.date); if (asOf && dt && dt > asOf) return; (j.lines || []).forEach(l => { if (String(l.acct) !== String(acct)) return; const c = crControl(j, l) || "(NO CONTROL)"; const o = by[c] || (by[c] = { control: c, desc: "", bal: 0, first: null }); o.bal += crNum(l.dr) - crNum(l.cr); if (!o.desc) o.desc = (l.desc || j.desc || "").toUpperCase(); if (dt && (!o.first || dt < o.first)) o.first = dt; }); }); const rows = Object.values(by).filter(o => Math.abs(o.bal) > 0.004).map(o => ({ ...o, bal: crR2(o.bal), days: crDays(o.first, asOf) })) .sort((a, b) => a.control.localeCompare(b.control, undefined, { numeric: true })); const over = rows.filter(r => r.days > 30); return { rows, total: crR2(rows.reduce((s, r) => s + r.bal, 0)), count: rows.length, over30: crR2(over.reduce((s, r) => s + r.bal, 0)), over30n: over.length }; }; // Rows that match a filter box (control # / name / description / amount / age) — every word must match. const crSchedFilter = (rows, filter) => { const toks = crParseTokens(filter); if (!toks.length) return rows; return rows.filter(r => { const hay = crHay([r.control, r.desc], [...crAmtKeys(r.bal), r.days + "days", r.days + " days"]); return toks.every(t => crHasTok(hay, t)); }); }; const crSchedTotals = (rows) => { const over = rows.filter(r => r.days > 30); return { total: crR2(rows.reduce((s, r) => s + r.bal, 0)), count: rows.length, over30: crR2(over.reduce((s, r) => s + r.bal, 0)), over30n: over.length }; }; // Sub-ledger detail brought in by Upload tool ▸ Aged schedule (never posted to the GL): 261 → factory receivable rows, 220 / 220A → A/R transactions, // each flagged imported + schedule + asOf. Days are the days PRINTED on the old schedule (as of its print date). const crImpSched = (acct) => { const D = crD(), a = String(acct), rows = []; let asOfKey = ""; try { (D.factoryReceivables ? D.factoryReceivables() : []).forEach(f => { if (f && f.imported && String(f.schedule || f.glAcct) === a) { rows.push({ control: String(f.deal || ""), desc: /^Schedule \S+$/.test(String(f.program || "")) ? "" : String(f.program || "").toUpperCase(), bal: crNum(f.claimed != null ? f.claimed : f.amount), days: crNum(f.ageAsOf != null ? f.ageAsOf : f.age) }); asOfKey = asOfKey || String(f.asOfKey || ""); } }); (D.arCustomers ? D.arCustomers() : []).forEach(c => (c.txns || []).forEach(t => { if (t && t.imported && String(t.schedule) === a) { rows.push({ control: String(c.acct || ""), desc: String(c.name || "").toUpperCase(), bal: crNum(t.charge) - crNum(t.credit), days: crNum(t.days) }); asOfKey = asOfKey || String(t.asOfKey || ""); } })); } catch (e) {} rows.sort((x, y) => x.control.localeCompare(y.control, undefined, { numeric: true }) || x.bal - y.bal); const m = /^(\d{4})(\d{2})(\d{2})$/.exec(asOfKey); return { rows, asOf: m ? new Date(+m[1], +m[2] - 1, +m[3]) : null }; }; // The imported schedule printed under the GL-built one, with its own totals and a reconciliation line against the GL balance. const crImpSchedHtml = (acct, filter) => { const imp = crImpSched(acct); if (!imp.rows.length) return ""; const toks = crParseTokens(filter); const shown = toks.length ? crSchedFilter(imp.rows, filter) : imp.rows; const f = crSchedTotals(shown), all = crSchedTotals(imp.rows); const glThen = imp.asOf ? crScheduleData(acct, imp.asOf).total : null, glNow = crScheduleData(acct, null).total; const lbl = toks.length ? " (filtered)" : ""; const tail = `FINAL TOTAL${lbl}${crAmt(f.total)} COUNT TOTAL${lbl}${f.count} OVER 30 $${lbl}${crAmt(f.over30)} OVER 30 CNT${lbl}${f.over30n}` + (toks.length ? `ALL FINAL TOTAL (unfiltered)${crAmt(all.total)} ALL COUNT TOTAL (unfiltered)${all.count}` : "") + `GL ${crEsc(acct)} BALANCE${imp.asOf ? " AS OF " + crMDY(imp.asOf) : ""}${crAmt(glThen == null ? 0 : glThen)} DIFFERENCE (imported schedule - GL)${crAmt(all.total - (glThen == null ? 0 : glThen))} GL ${crEsc(acct)} BALANCE NOW (all dates)${crAmt(glNow)}`; return `
` + crHead(`Schedule 0 ${crAcctName(acct).toUpperCase()} - IMPORTED FROM THE OLD DMS`, [imp.asOf ? "printed as of " + crMDY(imp.asOf) : "", toks.length ? `filtered: ${shown.length} of ${imp.rows.length} (${String(filter).trim()})` : ""].filter(Boolean).join(" ")) + (toks.length ? `
FILTERED SCHEDULE - showing ${shown.length} of ${imp.rows.length} imported items. The filtered totals are NOT the schedule total.
` : "") + ` ${shown.map(r => ``).join("")} ${tail}
Control NumberDescriptionAge (printed)${crEsc(acct)} ${crEsc(crAcctName(acct).toUpperCase().slice(0, 18))}
${crEsc(r.control)}${crEsc(r.desc.slice(0, 42))}${r.days} DAYS${crAmt(r.bal)}
This is the old DMS's own schedule (sub-ledger detail from Upload tool ▸ Aged schedule), as printed - it is NOT posted to the general ledger and does not change the schedule above. A difference to the GL is normal when the trial balance was posted at a different date than this schedule was printed.
`; }; const crScheduleHtml = (acct, asOf, filter) => { if (!acct) return `
Pick an account.
`; const s = crScheduleData(acct, asOf); const toks = crParseTokens(filter); const shown = toks.length ? crSchedFilter(s.rows, filter) : s.rows; const f = crSchedTotals(shown); const sub = [asOf ? "as of " + crMDY(asOf) : "", toks.length ? `filtered: ${shown.length} of ${s.rows.length} (${String(filter).trim()})` : ""].filter(Boolean).join(" "); const tail = toks.length ? `FINAL TOTAL (filtered)${crAmt(f.total)} COUNT TOTAL (filtered)${f.count} OVER 30 $ (filtered)${crAmt(f.over30)} OVER 30 CNT (filtered)${f.over30n} ALL FINAL TOTAL (unfiltered)${crAmt(s.total)} ALL COUNT TOTAL (unfiltered)${s.count} ALL OVER 30 $ (unfiltered)${crAmt(s.over30)} ALL OVER 30 CNT (unfiltered)${s.over30n}` : `FINAL TOTAL${crAmt(s.total)} COUNT TOTAL${s.count} OVER 30 $${crAmt(s.over30)} OVER 30 CNT${s.over30n}`; return crHead(`Schedule 0 ${crAcctName(acct).toUpperCase()}`, sub) + (toks.length ? `
FILTERED SCHEDULE - showing ${shown.length} of ${s.rows.length} items. The filtered totals below are NOT the schedule total; the full schedule total is the "ALL" block.
` : "") + ` ${shown.map(r => ``).join("")} ${tail}
Control NumberDescriptionAge${crEsc(acct)} ${crEsc(crAcctName(acct).toUpperCase().slice(0, 18))}
${crEsc(r.control)}${crEsc(r.desc.slice(0, 42))}${r.days} DAYS${crAmt(r.bal)}
Built from the general ledger: each control (stock #, RO, deal or reference) with a balance in ${crEsc(acct)}; age = days since its first posting. Opening balances from an imported trial balance have no control # and show as one line (OB-...)${crImpSched(acct).rows.length ? " - the old DMS's own detail behind it is printed below." : "."}
` + crImpSchedHtml(acct, filter); }; /* ═══ 7. GL ACCOUNT DETAIL ════════════════════════════════════════════════ */ const crDetailRows = (acct, ctl, amt) => { const out = []; crJEs().forEach(j => (j.lines || []).forEach(l => { if (String(l.acct) !== String(acct)) return; const a = crR2(crNum(l.dr) - crNum(l.cr)); const c = crControl(j, l); if (ctl && !c.includes(String(ctl).toUpperCase())) return; if (amt && Math.abs(Math.abs(a) - Math.abs(crNum(amt))) > 0.005) return; out.push({ jr: j.jrn || "", posted: j.postedAt || j.createdAt || j.date, doc: j.ref || j.dealId || j.id, je: j.id, trans: j.date, control: c, desc: (l.desc || j.desc || "").toUpperCase(), amt: a, month: crMonthLbl(crPeriod(j)), period: crPeriod(j) }); })); return out.sort((a, b) => String(b.period).localeCompare(String(a.period)) || (crDate(b.trans) - crDate(a.trans))); }; const crDetailHtml = (acct, ctl, amt) => { if (!acct) return `
Pick an account.
`; const rows = crDetailRows(acct, ctl, amt); const bal = rows.reduce((s, r) => s + r.amt, 0); return crHead(`ACCOUNT DETAIL ${acct} ${crAcctName(acct).toUpperCase()}`, ctl || amt ? `filter: ${ctl ? "control " + ctl : ""} ${amt ? "amount " + amt : ""}` : "") + ` ${rows.map(r => ``).join("")}
JR-POSTED--DOCNO--TRANS--CNTRL-DESCRIPTIONAMOUNTMONTH
${crEsc(r.jr)}${crMDY(r.posted)}${crEsc(String(r.doc).slice(0, 10))}${crMDY(r.trans)}${crEsc(r.control.slice(0, 10))}${crEsc(r.desc.slice(0, 36))}${crAmt(r.amt)}${r.month}
${rows.length} line(s) — balance${ctl || amt ? " of shown lines" : ""}${crAmt(bal)}
`; }; /* ═══ 8. ACCOUNT HISTORY ══════════════════════════════════════════════════ */ const crHistoryHtml = (acct, months) => { if (!acct) return `
Pick an account.
`; const per = {}; crJEs().forEach(j => (j.lines || []).forEach(l => { if (String(l.acct) !== String(acct)) return; const p = crPeriod(j) || "unknown"; const o = per[p] || (per[p] = { p, amt: 0, ye: false, docs: new Set() }); o.amt += crNum(l.dr) - crNum(l.cr); o.docs.add(j.id); if (/year.?end/i.test(j.desc || "") || /^YE/i.test(j.id || "")) o.ye = true; })); const list = Object.values(per).sort((a, b) => a.p.localeCompare(b.p)); let run = 0; list.forEach(o => { run += o.amt; o.bal = crR2(run); o.amt = crR2(o.amt); }); const shown = list.slice(-(months || 24)).reverse(); return crHead(`ACCOUNT HISTORY FOR ${acct} ${crAcctName(acct).toUpperCase()}`) + ` ${shown.map(o => ``).join("")}
MONTHAMOUNTBALANCEDESCRIPTIONENTRIES
CURRENT${crAmt(run)}
${crMonthLbl(o.p)}${crAmt(o.amt)}${crAmt(o.bal)}${o.ye ? "YEAR END TRANSFER" : "MONTH ACTIVITY"}${o.docs.size}
`; }; /* ═══ 9. GM DEALER OPERATING REPORT — PAGE 1 (live) ══════════════════════ */ const CR_GM_ASSETS = [ ["Cash on hand", (a) => /^20[01]/.test(a)], ["Cash in bank", (a) => /^20[234]/.test(a)], ["Contracts in transit", (a) => /^205/.test(a)], ["Securities", (a) => /^206/.test(a)], ["Customer receivables", (a) => /^22/.test(a)], ["Factory receivables", (a) => /^261/.test(a)], ["Due from finance companies", (a) => /^26[02]/.test(a)], ["Warranty claims", (a) => /^263/.test(a)], ["Other receivables", (a) => /^26[4-9]|^2[5]/.test(a)], ["New cars", (a) => /^23[0-6]/.test(a), "inv"], ["New trucks", (a) => /^23[7-9]/.test(a), "inv"], ["Used cars", (a) => /^240/.test(a), "inv"], ["Used trucks", (a) => /^241/.test(a), "inv"], ["Parts & accessories", (a) => /^24[2-3]/.test(a)], ["Tires / gas / oil / paint", (a) => /^24[4-5]/.test(a)], ["Sublet & work in process", (a) => /^24[6-9]/.test(a)], ["Prepaid expenses", (a) => /^27/.test(a)], ["Fixed assets (net)", (a) => /^28/.test(a)], ["Other assets", (a) => /^29/.test(a)], ]; const crGmHtml = (year) => { const y = String(year || new Date().getFullYear()); const bal = {}; const pl = {}; crJEs().forEach(j => { const p = crPeriod(j); (j.lines || []).forEach(l => { const a = String(l.acct); const v = crNum(l.dr) - crNum(l.cr); bal[a] = (bal[a] || 0) + v; if ((/^0/.test(a) || +a.replace(/\D/g, "") >= 400) && p.startsWith(y)) pl[p] = (pl[p] || 0) + v; }); }); const accts = Object.keys(bal); const sum = (f) => crR2(accts.filter(f).reduce((s, a) => s + bal[a], 0)); const units = (f) => crInv().filter(u => window.DealCalc.unitOnLot(u) && f(String(u.invAcct || ""))).length; // RC28 fix round: the ONE on-lot reading const isA = (a) => /^2/.test(a), isL = (a) => /^3[0-3]/.test(a), isNW = (a) => /^3[4-9]/.test(a), isPL = (a) => /^0/.test(a) || +a.replace(/\D/g, "") >= 400; const tA = sum(isA), tL = crR2(-sum(isL)), tNW = crR2(-sum(isNW)), cur = crR2(-sum(isPL)); const used = new Set(); const aRows = CR_GM_ASSETS.map(([label, f, inv]) => { const v = sum(a => isA(a) && f(a) && !used.has(a)); accts.filter(a => isA(a) && f(a)).forEach(a => used.add(a)); return `${label}${inv ? units(f) : ""}${crAmt(v)}`; }).join(""); const unclassA = sum(a => isA(a) && !used.has(a)); const lRows = accts.filter(isL).sort().map(a => `${crEsc(a)} ${crEsc(crAcctName(a))}${crAmt(-bal[a])}`).join(""); const nwRows = accts.filter(isNW).sort().map(a => `${crEsc(a)} ${crEsc(crAcctName(a))}${crAmt(-bal[a])}`).join(""); // monthly P&L with retail units (posted deals by month) const deals = crDeals().filter(d => d.postedToGL); const months = Array.from({ length: 12 }, (_, i) => `${y}-${String(i + 1).padStart(2, "0")}`); const mRows = months.map(p => { const ds = deals.filter(d => { const dt = crDate(d.delivered || d.date); return dt && `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}` === p; }); const nN = ds.filter(d => /^n/i.test(d.type || "")).length, nU = ds.length - nN; if (!ds.length && !pl[p]) return ""; return `${crMonthLbl(p)}${nN}${nU}${crAmt(-(pl[p] || 0))}`; }).join(""); const ytd = crR2(-Object.values(pl).reduce((s, v) => s + v, 0)); const diff = crR2(tA - tL - tNW - cur); return crHead("GENERAL MOTORS DEALER OPERATING REPORT — PAGE 1 (LIVE)", `January ${y} through today`) + `
${aRows} ${Math.abs(unclassA) > 0.004 ? `` : ""}
ASSETSUNITSAMOUNT
Other 2xx accounts${crAmt(unclassA)}
TOTAL ASSETS${crAmt(tA)}
${lRows} ${nwRows}
LIABILITIESAMOUNT
TOTAL LIABILITIES${crAmt(tL)}
Current profit or loss (all P&L accounts)${crAmt(cur)}
TOTAL NET WORTH${crAmt(tNW + cur)}
TOTAL LIABILITIES & NET WORTH${crAmt(tL + tNW + cur)}

PROFIT OR LOSS SUMMARY ${y}

${mRows || ``}
MONTHRTL NEWRTL USEDPROFIT/LOSS
No activity
TOTAL${crAmt(ytd)}
Live from the K2B ledger. Units = unsold vehicles in each inventory account; retail units = deals posted that month. ${Math.abs(diff) > 0.01 ? `Assets differ from liabilities + net worth by ${crAmt(diff)} — an account outside the 2xx/3xx/P&L ranges has a balance.` : "Assets = liabilities + net worth ✓"}
`; }; /* ═══ 10. VEHICLE MASTER REPORT ═══════════════════════════════════════════ */ const crVehOnReport = (u) => u && window.DealCalc.unitOnLot(u); // RC28 fix round: the ONE on-lot reading (lib/deal-calc.js) // A unit's balance in its own inventory account, from the ledger. Same rule as DMS.unitGL (an entry belongs to the unit when its // stock = the unit, its deal is a deal of the unit, or its text mentions the stock # / VIN) but it only looks at entries that touch the // inventory account and compiles one RegExp per unit - DMS.unitGL scans every journal line and builds a RegExp for each of them, // which takes seconds per report once the journal holds ~10,000 entries. const crGlCtx = () => { const byAcct = new Map(), dealsByStock = new Map(); crJEs().forEach(j => { if (!j) return; const seen = new Set(); (j.lines || []).forEach(l => { const a = l && l.acct; if (a == null || seen.has(a)) return; seen.add(a); let arr = byAcct.get(a); if (!arr) byAcct.set(a, arr = []); arr.push(j); }); }); crDeals().forEach(d => { if (!d) return; const st = String(d.stock || "").toUpperCase(); if (!st) return; let set = dealsByStock.get(st); if (!set) dealsByStock.set(st, set = new Set()); set.add(d.id); }); return { byAcct, dealsByStock }; }; const crUnitInvGL = (u, ctx) => { const A = crD()._ACCT || {}, isNew = /^n/i.test(String(u.type || "")); const inv = u.invAcct || (isNew ? A.invNewCar : A.invUsedCar); const stock = String(u.stock).toUpperCase(), vin = String(u.vin || "").toUpperCase(); const dealIds = ctx.dealsByStock.get(stock) || null; const re = stock ? new RegExp("\\b" + stock.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&") + "\\b") : null; const mentions = (t) => { const x = String(t || "").toUpperCase(); return !!((re && re.test(x)) || (vin && x.includes(vin))); }; let bal = 0, booked = false; (ctx.byAcct.get(inv) || []).forEach(j => { const mine = String(j.stock || "").toUpperCase() === stock || !!(j.dealId && dealIds && dealIds.has(j.dealId)) || mentions(j.desc) || (j.lines || []).some(l => mentions(l.desc)); if (!mine) return; booked = true; bal += (j.lines || []).filter(l => l.acct === inv).reduce((x, l) => x + (+l.dr || 0) - (+l.cr || 0), 0); }); return { booked, bal: +bal.toFixed(2) }; }; const crVehRows = (units) => { const ctx = crGlCtx(); return (units || crInv().filter(crVehOnReport)).map(u => { const basis = crD().costBasis ? crD().costBasis(u.stock) : { found: false }; const purchase = crNum(u.invoiceCost || u.cost); const repairs = crR2((basis.found ? basis.reconTotal : 0) + crNum(u.recon)); const total = crR2(purchase + repairs + crNum(u.pack)); let gl = null, glRef = false; try { const g = crUnitInvGL(u, ctx); if (g.booked) gl = g.bal; } catch (e) {} // an imported unit the ledger has no entry for (the trial balance holds the account total only): GL COST = the cost the old DMS's vehicle master printed (shown grey) if (gl == null && u.imported && u.costBooked != null && u.costBooked !== "" && Number.isFinite(+u.costBooked)) { gl = crR2(u.costBooked); glRef = true; } const isN =/^n/i.test(u.type || ""), isT = /truck/i.test((u.type || "") + " " + (u.subType || "") + " " + (u.body || "")); return { u, inv: String(u.invAcct || (isN ? (isT ? "237" : "231") : (isT ? "241" : "240"))), year: u.yr, nu: /^n/i.test(u.type || "") ? "N" : "U", make: String(u.mk || "").toUpperCase().slice(0, 7), model: String(u.mdl || "").toUpperCase().slice(0, 9), color: String(u.color || u.extColor || "").toUpperCase().slice(0, 6), stock: u.stock, booked: gl != null && !glRef, glRef, invDate: u.invoiceDate || u.stockedDate, age: u.age != null && u.age !== "" ? crNum(u.age) : crDays(u.invoiceDate || u.stockedDate), retail: crNum(u.asking), purchase, repairs, total, gl: gl == null ? 0 : gl, vin: u.vin || "", miles: u.miles }; }); }; // filter = the filter box: stock, VIN fragment (full or last 4-8), year / make / model, color, inventory account, N / U (or new / used). // Rows, subtotals, Total Cars and Averages are recomputed on the filtered set and the heading says "filtered: x of y". const crVehHtml = (sort, filter) => { let units = crInv().filter(crVehOnReport); const allCount = units.length; const ftoks = crParseTokens(filter, { nu: true }); if (ftoks.length) { const keep = new Set(crSearch(crGetIndex("vehicle"), filter).list.map(it => String(it.key))); units = units.filter(u => keep.has(String(u.stock))); } const rows = crVehRows(units); const key = sort === "vin" ? (r) => r.vin : sort === "date" ? (r) => String(crMDY(r.invDate)).slice(6) + crMDY(r.invDate) : (r) => r.inv + "|" + r.stock; rows.sort((a, b) => String(key(a)).localeCompare(String(key(b)), undefined, { numeric: true })); const sumOf = (rs, k) => crR2(rs.reduce((s, r) => s + crNum(r[k]), 0)); const line = (r) => `${crEsc(r.year)}${r.nu}${crEsc(r.make)}${crEsc(r.model)}${crEsc(r.color)}${crEsc(r.stock)}${crMDY(r.invDate)}${r.age}${r.retail ? Math.round(r.retail) : ""}${crAmt(r.purchase)}${r.repairs ? crAmt(r.repairs) : "N/A"}${crAmt(r.total)}${r.booked ? `${crAmt(r.gl)}` : r.glRef ? `${crAmt(r.gl)}` : `not booked`}${crEsc(r.vin)}${r.miles != null ? r.miles + "-A" : ""}`; const subtotal = (label, rs) => `${crEsc(label)} VEHICLES ${rs.length}${Math.round(sumOf(rs, "retail"))}${crAmt(sumOf(rs, "purchase"))}${crAmt(sumOf(rs, "repairs"))}${crAmt(sumOf(rs, "total"))}${crAmt(sumOf(rs, "gl"))}`; let body = ""; if (!sort || sort === "acct") { const groups = {}; rows.forEach(r => (groups[r.inv] = groups[r.inv] || []).push(r)); Object.keys(groups).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).forEach(g => { body += groups[g].map(line).join("") + subtotal(g, groups[g]); }); } else body = rows.map(line).join(""); const n = rows.length || 1; const fl = ftoks.length ? `filtered: ${rows.length} of ${allCount}` : ""; return crHead("VEHICLE MASTER REPORT", "SORT BY: " + (sort === "vin" ? "VIN" : sort === "date" ? "INVOICE DATE" : "INV ACCT") + (fl ? " " + fl + ` (${String(filter).trim()})` : "")) + (fl ? `
FILTERED REPORT - ${fl}. These are NOT all the vehicles: the subtotals, Total Cars and Averages below cover only the ${rows.length} vehicle${rows.length === 1 ? "" : "s"} shown.
` : "") + `${body}
YEARN/UMAKEMODELCOLORSTOCK #INVOICE DATEAGERETAIL PRICEPURCHASE COSTTOTAL REPAIRSTOTAL COSTGL COSTVINMILES
Total Cars ${rows.length}${Math.round(sumOf(rows, "age") / n)}${Math.round(sumOf(rows, "retail"))}${crAmt(sumOf(rows, "purchase"))}${crAmt(sumOf(rows, "repairs"))}${crAmt(sumOf(rows, "total"))}${crAmt(sumOf(rows, "gl"))}
Averages${Math.round(sumOf(rows, "retail") / n)}${crAmt(sumOf(rows, "purchase") / n)}${crAmt(sumOf(rows, "repairs") / n)}${crAmt(sumOf(rows, "total") / n)}${crAmt(sumOf(rows, "gl") / n)}
TOTAL COST = purchase + repairs + pack. GL COST = the unit's balance in its inventory account (red when it differs from purchase + repairs — look at that unit's Accounting tab; "not booked" = no purchase entry in the ledger yet). Grey GL COST = a unit brought in by the vehicle-master import that the ledger has no entry for: it shows the cost the old DMS printed (the account total sits in the imported trial balance).
`; }; /* ═══ SEARCH ENGINE ═══════════════════════════════════════════════════════ */ // One lowercase "haystack" per record, built once per data version (collection length + first / last id + sum of // record revisions) and reused for every keystroke. Rules: several words = AND; case, punctuation and accents are // ignored; partial words are fine ("lanc" finds LANCASTER); phones match on digits only (any punctuation); // amounts match 23995 / 23,995.00 / $23,995; dates match 9/29, 09-29-26, 2026-09-29 and month words ("sept"). const CR_MON = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; const CR_MONF = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"]; const CR_PAGE = 50; // most result rows ever rendered at once const CR_DEBOUNCE = 120; // ms between the last keystroke and the search const CR_ANY_PER = 6; // rows per group in "Find anything" const crP2 = (n) => String(n).padStart(2, "0"); const crFold = (s) => { let t = String(s == null ? "" : s); try { t = t.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); } catch (e) {} return t.toLowerCase(); }; // same length as the input (so highlight ranges map back onto the original text) const crFoldAligned = (s) => { let out = ""; for (let i = 0; i < s.length; i++) { const c = s[i]; let f = c; try { f = c.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); } catch (e) {} f = f.toLowerCase(); out += f.length === 1 ? f : (c.toLowerCase().length === 1 ? c.toLowerCase() : c); } return out; }; const crIdKey = (v) => crFold(v).replace(/[^a-z0-9]/g, ""); const crDateKeys = (v) => { const d = v ? crDate(v) : null; if (!d || isNaN(d.getTime())) return []; const m = d.getMonth(); return ["d:" + d.getFullYear() + "-" + crP2(m + 1) + "-" + crP2(d.getDate()), "m:" + crP2(m + 1) + "-" + crP2(d.getDate()), CR_MON[m], CR_MONF[m], m === 8 ? "sept" : ""]; }; // canonical amount strings: 23995.00 and 23995 (+ unsigned forms of negatives) so 23995 / 23,995.00 / $23,995 all hit const crAmtKeys = (n) => { const v = crR2(n); if (!v) return []; const out = [v.toFixed(2)]; if (v < 0) out.push((-v).toFixed(2)); if (Number.isInteger(v)) { out.push(String(v)); if (v < 0) out.push(String(-v)); } return out; }; // fields = free text (words / punctuation folded away); extras = canonical keys appended as-is (dates, amounts, digits) const crHay = (fields, extras) => { const f = fields.map(crFold).join(" "); const ex = " " + (extras || []).filter(Boolean).join(" ") + " "; return { h: " " + f.replace(/[^a-z0-9]+/g, " ") + ex, s: " " + f.replace(/[^a-z0-9 ]+/g, "") + ex }; }; const crValidMD = (m, d) => m >= 1 && m <= 12 && d >= 1 && d <= 31; const crTok = (raw, o) => { const f = crFold(raw).trim(); if (!f) return null; if (o && o.nu) { if (f === "n" || f === "new") return { h: "nu_n", s: "nu_n", kind: "nu" }; if (f === "u" || f === "used") return { h: "nu_u", s: "nu_u", kind: "nu" }; } let m; if ((m = /^(\d{4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})$/.exec(f)) && crValidMD(+m[2], +m[3])) { const k = "d:" + m[1] + "-" + crP2(m[2]) + "-" + crP2(m[3]); return { h: k, s: k, kind: "date", mo: +m[2], d: +m[3], y: m[1] }; } if ((m = /^(\d{1,2})[\/\-.](\d{1,2})[\/\-.](\d{4}|\d{2})$/.exec(f)) && crValidMD(+m[1], +m[2])) { const y = m[3].length === 2 ? "20" + m[3] : m[3]; const k = "d:" + y + "-" + crP2(m[1]) + "-" + crP2(m[2]); return { h: k, s: k, kind: "date", mo: +m[1], d: +m[2], y }; } if ((m = /^(\d{1,2})[\/\-.](\d{1,2})$/.exec(f)) && crValidMD(+m[1], +m[2])) { const k = "m:" + crP2(m[1]) + "-" + crP2(m[2]); return { h: k, s: k, kind: "date", mo: +m[1], d: +m[2] }; } const n = f.replace(/^\$/, ""); if (/^-?(\d+|\d{1,3}(,\d{3})+)(\.\d+)?$/.test(n)) { const c = n.replace(/,/g, ""), t = { h: c, s: c, kind: "num" }; // 555.0142 is a phone fragment as well as a decimal number: if the number reading finds nothing, try it as plain text if (/[.,]/.test(f)) t.alt = { h: f.replace(/[^a-z0-9]+/g, " ").trim(), s: f.replace(/[^a-z0-9]+/g, ""), kind: "text" }; return t; } const sq = f.replace(/[^a-z0-9]+/g, ""); if (!sq) return null; return { h: f.replace(/[^a-z0-9]+/g, " ").trim(), s: sq, kind: "text" }; }; const crParseTokens = (q, o) => String(q == null ? "" : q).trim().split(/\s+/).slice(0, 10).map(w => crTok(w, o)).filter(Boolean); // regexes (run on the folded text) that find the typed words again in a result line, so they can be highlighted const crHiRes = (toks) => toks.reduce((a, t) => a.concat(t.alt ? [t, t.alt] : [t]), []).map(t => { try { if (t.kind === "nu") return null; if (t.kind === "date") { const md = "0?" + t.mo + "[\\-\\/.]0?" + t.d; return new RegExp(t.y ? md + "[\\-\\/.](?:" + String(t.y).slice(-2) + "|" + t.y + ")" : md, "g"); } const digitsOnly = /^\d+$/.test(t.s); if (t.kind === "num" && !digitsOnly) return new RegExp(t.h.split("").map(ch => ch.replace(/[.\-]/g, "\\$&")).join(",?"), "g"); return new RegExp(t.s.split("").join(digitsOnly ? "[^0-9a-z]{0,2}" : "['.\\-\\/]?"), "g"); } catch (e) { return null; } }).filter(Boolean); const crHi = (text, res) => { const s = String(text == null ? "" : text); if (!s || !res || !res.length) return s; const f = crFoldAligned(s); const mark = new Uint8Array(s.length); let any = false; res.forEach(re => { re.lastIndex = 0; let m, guard = 0; while ((m = re.exec(f)) && guard++ < 60) { if (!m[0]) { re.lastIndex++; continue; } for (let k = m.index; k < m.index + m[0].length; k++) mark[k] = 1; any = true; } }); if (!any) return s; const out = []; let i = 0, key = 0; while (i < s.length) { let j = i; const on = mark[i]; while (j < s.length && mark[j] === on) j++; const seg = s.slice(i, j); out.push(on ? {seg} : seg); i = j; } return out; }; // how well one word hits one haystack: whole word 40, start of a word 25, anywhere 10 (squashed forms a little lower); 0 = no hit const crTokW = (h, s, t) => { let w = 0; if (h.indexOf(" " + t.h + " ") >= 0) w = 40; else if (h.indexOf(" " + t.h) >= 0) w = 25; else if (h.indexOf(t.h) >= 0) w = 10; else if (s.indexOf(" " + t.s + " ") >= 0) w = 36; else if (s.indexOf(" " + t.s) >= 0) w = 22; else if (s.indexOf(t.s) >= 0) w = 8; if (!w && t.alt) w = crTokW(h, s, t.alt); return w; }; const crHasTok = (hay, t) => crTokW(hay.h, hay.s, t) > 0; // ranking helpers: exact / prefix id hits, VIN + phone tails, exact amounts and exact words (stage …) rank first const crBonus = (it, t) => { let b = 0; const s = t.s; if (it.ids) for (let i = 0; i < it.ids.length; i++) { const id = it.ids[i]; if (id === s) { b += 100; break; } if (id.startsWith(s)) b = Math.max(b, 50); } if (s.length >= 4 && it.tails) for (let i = 0; i < it.tails.length; i++) if (it.tails[i].endsWith(s)) { b += 60; break; } if (it.amts && it.amts.indexOf(t.h) >= 0) b += 80; if (it.words && it.words.indexOf(s) >= 0) b += 35; return b; }; // → { list: records in rank order (all of them), toks, hl }. Empty query → the base order (newest first / by number). const crSearch = (idx, q) => { const toks = crParseTokens(q, { nu: idx.kind === "vehicle" }); if (!toks.length) return { list: idx.items, toks, hl: [] }; const items = idx.items, nt = toks.length, out = []; // RC31 (fx31, RC30 acceptance F6): a deal / a customer is found by an OLD 10-digit number or a phone in any format of its account (DMS.custQueryHit — // the one reading every search box shares); the words keep their own scoring const D0 = window.DMS; const byCust = (idx.kind === "deal" || idx.kind === "customer") && D0 && D0.custNumsFor && D0.custNumsFor(q).length ? (it) => { const r = it.ref || {}; return D0.custQueryHit(q, idx.kind === "customer" ? (r.custNum || it.key) : r.custNum, [r.phone, r.cell]); } : null; for (let i = 0; i < items.length; i++) { const it = items[i]; let sc = 0, ok = true; for (let k = 0; k < nt; k++) { const w = crTokW(it.h, it.s, toks[k]); if (!w) { ok = false; break; } sc += w + crBonus(it, toks[k]); } if (!ok && byCust && byCust(it)) { ok = true; sc = 150; } if (ok) out.push({ it, sc }); } out.sort((a, b) => b.sc - a.sc || a.it.ord - b.it.ord); return { list: out.map(x => x.it), toks, hl: crHiRes(toks) }; }; /* ── indexes: one per kind, rebuilt only when the data version changes ── */ const crVerArr = (arr, idf, fn) => { const a = arr || [], n = a.length; if (!n) return "0"; let rs = 0; for (let i = 0; i < n; i++) { const r = a[i]; if (r) rs += (+r.rev || 0) + (r.postedToGL ? 1 : 0) + (r.reversedBy ? 1 : 0) + (r.edits ? r.edits.length : 0) + (fn ? fn(r) : 0); } return n + "|" + idf(a[0]) + "|" + idf(a[n - 1]) + "|" + rs; }; const crVerCust = () => { const c = (crD().customers && crD().customers()) || {}; const ks = Object.keys(c); let h = 0; for (let i = 0; i < ks.length; i++) { const x = c[ks[i]] || {}; h = (h * 31 + (Date.parse(x.saved) % 100003 || 0) + String(x.lastName || "").length + String(x.phone || "").length) % 1000003; } return ks.length + "|" + ks[ks.length - 1] + "|" + h; }; const crGl = () => ((crD().glAccounts && crD().glAccounts()) || []); const crInvVer = () => crVerArr(crInv(), u => u && u.stock, u => String(u.status || "").length + String(u.invAcct || "").length); const crMkIndex = (kind, recs) => { const items = recs.filter(Boolean), byKey = new Map(), byUp = new Map(); items.forEach((it, i) => { it.ord = i; byKey.set(String(it.key), it); byUp.set(String(it.key).toUpperCase(), it); }); return { kind, items, byKey, byUp }; }; const crDealCob = (d) => d.cobuyer || (d.creditApp && d.creditApp.coBuyer && [d.creditApp.coBuyer.first, d.creditApp.coBuyer.last].filter(Boolean).join(" ")) || ""; const crDealDate = (d) => d.delivered || d.date || d.created; const crUsd0 = (n) => "$" + Math.round(crNum(n)).toLocaleString("en-US"); const crDealBuild = () => { const units = new Map(); crInv().forEach(u => { if (u && u.stock) units.set(String(u.stock).toUpperCase(), u); }); const recs = crDeals().map((d, i) => { if (!d || d.id == null) return null; const u = units.get(String(d.stock || "").toUpperCase()) || null, uu = u || {}; const vin = String(d.vin || uu.vin || ""), phone = String(d.phone || ""), dig = phone.replace(/\D/g, ""); const dtv = crDealDate(d), dt = dtv ? crDate(dtv) : null; const stage = String(d.stage || "").trim(); const words = [crIdKey(stage)]; if (d.postedToGL) words.push("posted"); if (d.imported) words.push("imported"); const hay = crHay([d.id, d.invoiceNo, d.invNo, d.buyer, crDealCob(d), d.stock, vin, d.vehicle, uu.yr, uu.mk, uu.mdl, uu.color, phone, d.salesperson, d.lender || "cash", (d.recap && d.recap.fiMgr) || d.fiManager, stage, d.status, d.tradeDesc, d.tradeStock, d.tradeVin, d.custNum, d.street, d.city, d.state, d.postal, d.postedToGL ? "posted" : "", d.imported ? "imported" : ""], [...crDateKeys(dtv), ...crAmtKeys(d.salePrice), ...crAmtKeys(d.amtFinanced), ...crAmtKeys(d.payment), dig.length >= 7 ? dig : ""]); return { key: String(d.id), ref: d, u, h: hay.h, s: hay.s, ids: [crIdKey(d.id), crIdKey(String(d.id).replace(/^\D+/, "")), crIdKey(d.invoiceNo), crIdKey(d.invNo), crIdKey(d.stock)].filter(Boolean), tails: [crFold(vin), dig].filter(Boolean), amts: crAmtKeys(d.salePrice), words, ts: dt ? dt.getTime() : 0, i }; }).filter(Boolean).sort((a, b) => b.ts - a.ts || a.i - b.i); return crMkIndex("deal", recs); }; const crDealView = (it) => { const d = it.ref, u = it.u || {}; const cob = crDealCob(d), dtv = crDealDate(d); const veh = [u.yr, u.mk, u.mdl || d.vehicle].filter(Boolean).join(" "); return { title: `${d.id} · ${String(d.buyer || "—").toUpperCase()}${cob ? " & " + String(cob).toUpperCase() : ""}`, sub: [d.stock, veh, d.vin || u.vin, d.phone, d.salesperson && String(d.salesperson).toUpperCase(), d.lender && String(d.lender).toUpperCase(), dtv && crDate(dtv) ? crMDY(dtv) : "", crNum(d.salePrice) ? crUsd0(d.salePrice) : ""].filter(Boolean).join(" · "), tag: d.stage || (d.postedToGL ? "Posted" : "") }; }; const crJeBuild = () => { const recs = crJEs().map((j, i) => { if (!j || j.id == null) return null; const lines = j.lines || [], accts = [], seen = new Set(), amts = new Set(), ctls = new Set(), descs = new Set(); lines.forEach(l => { const a = String(l.acct == null ? "" : l.acct).trim(); if (a && !seen.has(a)) { seen.add(a); accts.push(a); } crAmtKeys(crNum(l.dr) - crNum(l.cr)).forEach(k => amts.add(k)); const c = crControl(j, l); if (c) ctls.add(c); if (l.desc && descs.size < 6) descs.add(l.desc); }); const dt = j.date ? crDate(j.date) : null; const hay = crHay([j.id, j.ref, j.dealId, "journal " + (j.jrn || ""), "jrn " + (j.jrn || ""), j.desc, j.stock, j.ro, j.src, crPeriod(j), accts.join(" "), Array.from(ctls).join(" "), Array.from(descs).join(" "), j.reversedBy ? "reversed" : "", j.corrects ? "corrected" : ""], [...crDateKeys(j.date), ...Array.from(amts)]); return { key: String(j.id), ref: j, accts, h: hay.h, s: hay.s, ids: [crIdKey(j.id), crIdKey(j.ref), crIdKey(j.dealId), crIdKey(String(j.dealId || "").replace(/^\D+/, "")), crIdKey(j.stock)].filter(Boolean), accts2: accts.map(crIdKey), words: [String(j.jrn || ""), crIdKey(j.src)].filter(Boolean), amts: Array.from(amts), ts: dt ? dt.getTime() : 0, i }; }).filter(Boolean); recs.forEach(r => { r.ids = r.ids.concat(r.accts2); }); // a bare account number typed = exact hit on any entry that uses it recs.sort((a, b) => b.ts - a.ts || a.i - b.i); return crMkIndex("journal", recs); }; const crJeView = (it) => { const j = it.ref, lines = j.lines || []; return { title: `${j.id} · ${crMDY(j.date)} · ${String(j.desc || "").toUpperCase().slice(0, 56)}`, sub: `doc ${j.ref || j.dealId || j.id} · jrn ${j.jrn || "-"}${j.stock ? " · " + j.stock : ""} · ${lines.length} lines · accts ${it.accts.join(" ")}`, tag: crAmt(lines.reduce((s, l) => s + crNum(l.dr), 0)) }; }; const CR_TYPE_WORD = { A: "asset", L: "liability", S: "sale", C: "cost", E: "expense", I: "income" }; const crAcctBuild = () => { const coa = crCOA(), bal = {}; crJEs().forEach(j => (j.lines || []).forEach(l => { const a = String(l.acct == null ? "" : l.acct).trim(); if (a) bal[a] = (bal[a] || 0) + crNum(l.dr) - crNum(l.cr); })); Object.keys(bal).forEach(a => { if (!coa[a]) coa[a] = { acct: a, name: crAcctName(a), type: "" }; }); const recs = Object.values(coa).sort((a, b) => String(a.acct).localeCompare(String(b.acct), undefined, { numeric: true })).map(a => { const hay = crHay([a.acct, a.name, a.alt, a.type, CR_TYPE_WORD[a.type] || "", a.cost, a.inv, a.group], []); return { key: String(a.acct), ref: a, h: hay.h, s: hay.s, ids: [crIdKey(a.acct), crIdKey(a.alt)].filter(Boolean), bal: crR2(bal[a.acct] || 0) }; }); return crMkIndex("acct", recs); }; const crAcctView = (it) => { const a = it.ref; return { pill: a.type || "", title: `${a.acct} ${String(a.name || "").toUpperCase()}`, sub: [CR_TYPE_WORD[a.type] || a.type, a.alt && "alter " + a.alt, a.cost && "cost " + a.cost, a.inv && "inventory " + a.inv].filter(Boolean).join(" · "), tag: crAmt(it.bal) }; }; const crVehBuild = () => { const recs = crInv().map((u, i) => { if (!u || !u.stock) return null; const isN = /^n/i.test(u.type || ""); const hay = crHay([u.stock, u.vin, u.yr, u.mk, u.mdl, u.color || u.extColor, u.invAcct, u.type, u.subType, u.body, u.status, isN ? "new" : "used"], ["nu_" + (isN ? "n" : "u")]); return { key: String(u.stock), ref: u, h: hay.h, s: hay.s, ids: [crIdKey(u.stock)], tails: [crFold(u.vin || "")].filter(Boolean), i }; }).filter(Boolean); return crMkIndex("vehicle", recs); }; const crVehView = (it) => { const u = it.ref; return { title: `${u.stock} · ${[u.yr, u.mk, u.mdl].filter(Boolean).join(" ")}`, sub: [u.vin, u.color || u.extColor, u.invAcct && "inv acct " + u.invAcct, u.status].filter(Boolean).join(" · "), tag: /^n/i.test(u.type || "") ? "N" : "U" }; }; const crCustBuild = () => { const c = (crD().customers && crD().customers()) || {}; const recs = Object.keys(c).map((k, i) => { const x = c[k]; if (!x) return null; const num = String(x.custNum || k), dig = String(x.phone || x.cell || "").replace(/\D/g, ""); // RC30 (cn): the phone is the phone; the number is the number const aliases = Array.isArray(x.aliases) ? x.aliases.map(String) : []; const hh = Array.isArray(x.names) ? x.names.map((n) => n && [n.last, n.first].filter(Boolean).join(" ")) : []; const hay = crHay([num, x.lastName, x.firstName, x.middleName, [x.lastName, x.firstName].filter(Boolean).join(" "), x.phone, x.street, x.street2, x.city, x.state, x.postal, x.email].concat(aliases, hh), [dig.length >= 7 ? dig : ""]); return { key: num, ref: x, h: hay.h, s: hay.s, ids: [crIdKey(num)].concat(aliases.map(crIdKey)), tails: dig ? [dig] : [], i }; }).filter(Boolean).sort((a, b) => String(a.ref.lastName || "").localeCompare(String(b.ref.lastName || "")) || a.i - b.i); return crMkIndex("customer", recs); }; const crCustView = (it) => { const c = it.ref; return { title: [c.lastName, c.firstName].filter(Boolean).join(", ") || "(unnamed)", sub: ["Customer # " + c.custNum, c.phone || c.cell, [c.street, c.city, c.state, c.postal].filter(Boolean).join(" "), c.email].filter(Boolean).join(" · "), tag: "" }; }; const CR_KINDS = { deal: { label: "Deals", noun: "deals", ver: () => crVerArr(crDeals(), d => d && d.id) + "#" + crInvVer(), build: crDealBuild, view: crDealView }, journal: { label: "Journal documents", noun: "journal documents", ver: () => crVerArr(crJEs(), j => j && j.id), build: crJeBuild, view: crJeView }, acct: { label: "Accounts", noun: "accounts", ver: () => crVerArr(crJEs(), j => j && j.id) + "#" + crVerArr(crGl(), a => a && a.acct) + "#" + crInvVer(), build: crAcctBuild, view: crAcctView }, vehicle: { label: "Vehicles", noun: "vehicles", ver: crInvVer, build: crVehBuild, view: crVehView }, customer: { label: "Customers", noun: "customers", ver: crVerCust, build: crCustBuild, view: crCustView }, }; const CR_ANY_ORDER = ["deal", "journal", "acct", "vehicle", "customer"]; const CR_IDX = {}; const crGetIndex = (kind) => { const cfg = CR_KINDS[kind], ver = cfg.ver(), c = CR_IDX[kind]; if (c && c.ver === ver) return c.idx; const idx = cfg.build(); CR_IDX[kind] = { ver, idx }; return idx; }; /* ── last 8 picks per signed-in user (localStorage is only a convenience: always try/catch) ── */ const crRecKey = (kind) => { let u = ""; try { u = (crD().userId && crD().userId()) || ""; } catch (e) {} return `k2b-cr-recent:${u || "anon"}:${kind}`; }; const crRecentGet = (kind) => { try { const a = JSON.parse(localStorage.getItem(crRecKey(kind)) || "[]"); return Array.isArray(a) ? a.slice(0, 8) : []; } catch (e) { return []; } }; const crRecentPush = (kind, key) => { try { const a = crRecentGet(kind).filter(x => x !== String(key)); a.unshift(String(key)); localStorage.setItem(crRecKey(kind), JSON.stringify(a.slice(0, 8))); } catch (e) {} }; /* ═══ 11. TRIAL BALANCE ═══════════════════════════════════════════════════ */ // Live from the K2B journal, laid out like the old DMS print: ACCOUNT NUMBER · MONTH TO DATE (COUNT, AMOUNT) · // YEAR TO DATE (COUNT, AMOUNT) · DESCRIPTION, an "Account Total" under every 3-character account family with more than // one account (300 + 300D → 300), a "Group 300-399 Total" after each hundred-group and a final Total that must read 0.00. // + debit / − credit (same as the old print). // MONTH TO DATE = activity posted in the selected month (up to the "as of" day). // YEAR TO DATE = P&L accounts (0xxx and 400 and up): activity since January 1 of the selected year (fiscal year = calendar year); // balance-sheet accounts: the cumulative balance through the selected month. // Entries whose src is "Opening balances" (the imported old-DMS trial balance) count toward YEAR TO DATE / balance but NOT toward MONTH TO DATE. const crIsPL = (a) => /^0/.test(a) || +String(a).replace(/\D/g, "") >= 400; const crIsOpeningJE = (j) => /^opening balances?$/i.test(String((j && j.src) || "").trim()); const crMonthEnd = (month) => new Date(+month.slice(0, 4), +month.slice(5, 7), 0); const crISO = (d) => `${d.getFullYear()}-${crP2(d.getMonth() + 1)}-${crP2(d.getDate())}`; const crTbMonths = () => { const set = new Set(); crJEs().forEach(j => { const p = crPeriod(j); if (/^\d{4}-\d{2}$/.test(p)) set.add(p); }); let open = ""; try { open = (crD().periodStatus && crD().periodStatus().open) || ""; } catch (e) {} if (/^\d{4}-\d{2}$/.test(open)) set.add(open); return { list: Array.from(set).sort().reverse(), open }; }; const crMonthName = (p) => new Date(+p.slice(0, 4), +p.slice(5, 7) - 1, 1).toLocaleDateString("en-US", { month: "long", year: "numeric" }); const crTbGroup = (a) => { const m = /^(\d{3})/.exec(String(a)); return m ? Math.floor(+m[1] / 100) : -1; }; const crTbRoot = (a) => String(a).slice(0, 3); // month = "YYYY-MM"; asOfISO = "YYYY-MM-DD" ("" → end of month); filter = account search box. const crTbModel = (month, asOfISO, filter) => { const year = month.slice(0, 4), cutoff = asOfISO ? crDate(asOfISO) : null; const acc = {}; let openN = 0, skipped = 0, used = 0; // an opening-balance entry that was undone / reversed: its reversal (src "Adjustment", j.reverses = the opening entry) is not this month's activity either const openIds = new Set(); crJEs().forEach(j => { if (j && j.id != null && crIsOpeningJE(j)) openIds.add(j.id); }); crJEs().forEach(j => { const p = crPeriod(j); if (!/^\d{4}-\d{2}$/.test(p)) { skipped++; return; } if (p > month) return; if (p === month && cutoff) { const dt = j.date ? crDate(j.date) : null; if (dt && dt > cutoff) return; } const opening = crIsOpeningJE(j) || !!(j.reverses && openIds.has(j.reverses)); if (opening && !j.reverses && !j.reversedBy) openN++; used++; (j.lines || []).forEach(l => { const a = String(l.acct == null ? "" : l.acct).trim(); if (!a) return; const v = crNum(l.dr) - crNum(l.cr); const o = acc[a] || (acc[a] = { acct: a, mtd: 0, ytd: 0, prior: 0 }); if (crIsPL(a)) { if (p.slice(0, 4) === year) o.ytd += v; else o.prior += v; } else o.ytd += v; if (p === month && !opening) o.mtd += v; }); }); const coa = crCOA(); const all = Object.values(acc).map(o => ({ acct: o.acct, mtd: crR2(o.mtd), ytd: crR2(o.ytd), prior: crR2(o.prior), desc: String((coa[o.acct] && coa[o.acct].name) || crAcctName(o.acct) || "").toUpperCase(), alt: (coa[o.acct] && coa[o.acct].alt) || "" })) .filter(r => Math.abs(r.mtd) > 0.004 || Math.abs(r.ytd) > 0.004); const toks = crParseTokens(filter); const shown = !toks.length ? all.slice() : all.filter(r => { const acct = r.acct.toLowerCase(), hay = crHay([r.desc, r.alt], []); return toks.every(t => acct.startsWith(t.s) || acct.startsWith(t.h) || crHasTok(hay, t)); }); const gi = (r) => { const g = crTbGroup(r.acct); return g < 0 ? 99 : g; }; const cmp = (a, b) => gi(a) - gi(b) || crTbRoot(a.acct).localeCompare(crTbRoot(b.acct), undefined, { numeric: true }) || a.acct.localeCompare(b.acct, undefined, { numeric: true }); all.sort(cmp); shown.sort(cmp); const sum = (rs, k) => crR2(rs.reduce((s, r) => s + r[k], 0)); const rows = []; for (let i = 0; i < shown.length;) { const g = crTbGroup(shown[i].acct); let j = i; while (j < shown.length && crTbGroup(shown[j].acct) === g) j++; for (let k = i; k < j;) { const fam = crTbRoot(shown[k].acct); let e = k; while (e < j && crTbRoot(shown[e].acct) === fam) e++; for (let x = k; x < e; x++) rows.push({ kind: "acct", r: shown[x] }); if (e - k >= 2) rows.push({ kind: "family", label: fam, mtd: sum(shown.slice(k, e), "mtd"), ytd: sum(shown.slice(k, e), "ytd") }); rows.push({ kind: "gap" }); k = e; } const lo = g * 100; rows.push({ kind: "group", label: g < 0 ? "Other accounts Total" : `Group ${String(lo).padStart(3, "0")}-${String(lo + 99).padStart(3, "0")} Total`, mtd: sum(shown.slice(i, j), "mtd"), ytd: sum(shown.slice(i, j), "ytd") }); i = j; } return { month, year, asOfISO, rows, shown, all, filtered: toks.length > 0, mtd: sum(shown, "mtd"), ytd: sum(shown, "ytd"), fullMtd: sum(all, "mtd"), fullYtd: sum(all, "ytd"), priorPL: sum(all, "prior"), openingEntries: openN, entriesUsed: used, skipped }; }; const crTbHtml = (month, asOfISO, filter) => { if (!/^\d{4}-\d{2}$/.test(month || "")) return `
Pick a month.
`; const effective = asOfISO || crISO(crMonthEnd(month)); const m = crTbModel(month, asOfISO, filter); const rowHtml = (r) => { if (r.kind === "gap") return ``; if (r.kind === "acct") return `${crEsc(r.r.acct)}${crAmt(r.r.mtd)}${crAmt(r.r.ytd)}${crEsc(r.r.desc)}`; const lbl = r.kind === "family" ? "Account Total" : r.label; return `${r.kind === "family" ? crEsc(r.label) : ""}${crAmt(r.mtd)}${crAmt(r.ytd)}${crEsc(lbl)}`; }; const bad = Math.abs(m.fullMtd) > 0.005 || Math.abs(m.fullYtd) > 0.005; const fcls = (v) => (Math.abs(v) > 0.005 ? " cr-red" : ""); const totalRow = m.filtered ? `${crAmt(m.mtd)}${crAmt(m.ytd)}Total of the ${m.shown.length} accounts shown (filtered) ${crAmt(m.fullMtd)}${crAmt(m.fullYtd)}Total - all ${m.all.length} accounts` : `${crAmt(m.fullMtd)}${crAmt(m.fullYtd)}Total`; let why = ""; if (bad) { const priorExplains = Math.abs(m.fullYtd + m.priorPL) < 0.005 && Math.abs(m.fullMtd) < 0.005; why = priorExplains ? `
TOTAL IS NOT 0.00 (MONTH ${crAmt(m.fullMtd)} · YEAR ${crAmt(m.fullYtd)}). It is exactly the profit / loss of earlier years (${crAmt(m.priorPL)}) that has not been moved to retained earnings - post the year-end transfer in Accounting and this total will read 0.00.
` : `
TOTAL IS NOT 0.00 (MONTH ${crAmt(m.fullMtd)} · YEAR ${crAmt(m.fullYtd)}). The books are out of balance: look for a journal entry whose debits and credits differ (Journal Document report, last line "TOTAL" in red).
`; } else why = `
Total reads 0.00 / 0.00 - the ledger is in balance for this month.
`; const sub = `period ${crMonthName(month)} as of ${crMDY(effective)}${m.filtered ? ` filtered: ${m.shown.length} of ${m.all.length} accounts (${String(filter).trim()})` : ""}`; return crHead("TRIAL BALANCE", sub) + (m.filtered ? `
FILTERED REPORT - ${m.shown.length} of ${m.all.length} accounts. Family and group totals cover only the accounts shown; the full Total is on the last line.
` : "") + ` ${m.rows.map(rowHtml).join("")}${m.rows.length ? "" : ``} ${totalRow}
MONTH TO DATEYEAR TO DATE
ACCOUNT NUMBERCOUNTAMOUNTCOUNTAMOUNTDESCRIPTION
No accounts have activity for this month.
${why}
+ debit / − credit, as on the old print. MONTH TO DATE = activity of ${crEsc(crMonthName(month))}${asOfISO ? " through " + crMDY(asOfISO) : ""}. YEAR TO DATE = profit & loss accounts (0xxx and 400 up): activity since January 1, ${crEsc(m.year)}; balance-sheet accounts: balance through the month.${m.openingEntries ? ` ${m.openingEntries} opening-balance entr${m.openingEntries === 1 ? "y" : "ies"} (imported old-DMS trial balance) are included in YEAR TO DATE / balances but NOT in MONTH TO DATE.` : ""} COUNT is not tracked by K2B and stays blank. Click an account row to open its detail.${m.skipped ? ` ${m.skipped} journal entr${m.skipped === 1 ? "y has" : "ies have"} no usable date and ${m.skipped === 1 ? "is" : "are"} left out.` : ""}
`; }; /* ═══ FINDER UI ═══════════════════════════════════════════════════════════ */ const CR_UI_CSS = ` .cr-fi{display:flex;flex-direction:column;gap:2px;min-width:0} .cr-fi-line{display:flex;align-items:center;gap:6px;flex-wrap:wrap} .cr-fi-box{position:relative;display:flex;align-items:center;min-width:0} .cr-fi-box input.cr-fi-in{width:100%;box-sizing:border-box;padding:6px 26px 6px 28px;border:1px solid #AEB4BD;border-radius:5px;font:12.5px var(--jb-font,system-ui,sans-serif);background:#fff;color:#111;outline:none} .cr-fi-box input.cr-fi-in:focus{border-color:var(--jb-navy,#1B66A8);box-shadow:0 0 0 2px rgba(27,102,168,.18)} .cr-fi-ico{position:absolute;left:8px;top:50%;margin-top:-7px;width:14px;height:14px;color:#6B7280;pointer-events:none} .cr-fi-x{position:absolute;right:4px;top:50%;margin-top:-10px;width:20px;height:20px;border:0;border-radius:10px;background:#E4E7EC;color:#333;cursor:pointer;font-size:14px;line-height:18px;padding:0} .cr-fi-x:hover{background:#CBD0D8} .cr-fi-kbd{position:absolute;right:7px;top:50%;margin-top:-9px;font:11px/16px ui-monospace,monospace;color:#6B7280;border:1px solid #C8CCD2;border-radius:3px;padding:0 5px;background:#F6F7F9;pointer-events:none} .cr-fi-cnt{font-size:11.5px;color:#444;white-space:nowrap}.cr-fi-cnt b{color:#111} .cr-fi-pos{font-size:11.5px;color:#6B7280;white-space:nowrap} .cr-fi-cur{font-size:12px;color:#222;max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:#EEF4FB;border:1px solid #D4E2F2;border-radius:10px;padding:2px 9px} .cr-fi-hint{font-size:10.5px;color:#6B7280;line-height:1.35;max-width:640px} .cr-fi-step{min-width:26px} .cr-fd{position:absolute;top:calc(100% + 3px);left:0;z-index:70;width:640px;max-width:min(94vw,700px);max-height:min(430px,62vh);overflow:auto;background:#fff;border:1px solid #AEB4BD;border-radius:6px;box-shadow:0 10px 28px rgba(16,24,40,.22);font-family:system-ui,sans-serif;color:#111} .cr-fd-hd{display:flex;justify-content:space-between;gap:10px;padding:5px 10px;font-size:11px;color:#555;background:#F6F7F9;border-bottom:1px solid #E4E7EC;position:sticky;top:0;z-index:2} .cr-fd-sec{padding:4px 10px;font-size:10.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#5B6472;background:#FAFAFB;border-bottom:1px solid #EEF0F3} .cr-fd-row{display:flex;gap:10px;align-items:flex-start;padding:6px 10px;cursor:pointer;border-bottom:1px solid #F0F1F4} .cr-fd-row[data-active="1"]{background:var(--jb-blue-tint,#E5EFFA)} .cr-fd-main{flex:1;min-width:0} .cr-fd-t{font-size:12.5px;font-weight:600;line-height:1.3;word-break:break-word} .cr-fd-s{font-size:11px;color:#5B6472;line-height:1.35;word-break:break-word} .cr-fd-tag{font:600 11.5px/1.3 ui-monospace,Menlo,monospace;color:#333;white-space:nowrap;text-align:right;padding-top:1px} .cr-fd-cur{color:#1B66A8;font-weight:700} .cr-fd-empty,.cr-fd-more{padding:9px 10px;font-size:12px;color:#555} .cr-fd-more button{margin-left:6px} .cr-pill{display:inline-block;min-width:16px;text-align:center;font-size:10px;font-weight:700;color:#fff;background:#6B7280;border-radius:3px;padding:0 4px;margin-right:6px;vertical-align:1px} .cr-hl{background:#FFE08A;color:inherit;border-radius:2px;padding:0 1px} .cr-any{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:8px 14px;border-bottom:1px solid #D9DEE6;background:#F3F6FA} .cr-any .cr-fi-box{flex:1 1 360px;max-width:680px} .cr-any .cr-fd{width:min(94vw,760px)} .cr-fd-grp{padding:5px 10px;font-size:11px;font-weight:800;color:#11337A;background:#EAF0F8;border-bottom:1px solid #D5DFEC;display:flex;justify-content:space-between} .cr-tbrow{cursor:pointer}.cr-tbrow:hover td{background:#F1F6FC} `; const CrSearchIcon = () => ( ); const CrRow = ({ cfg, it, hl, active, selected, onPick, onHover, domId }) => { const v = cfg.view(it); return (
e.preventDefault()} onClick={() => onPick(it)} onMouseMove={onHover}>
{v.pill ? {v.pill} : null}{crHi(v.title, hl)}{selected ? ✓ showing : null}
{v.sub ?
{crHi(v.sub, hl)}
: null}
{v.tag ?
{crHi(v.tag, hl)}
: null}
); }; const crFromField = (e) => { const t = e && e.target; if (!t || t === window || t === document || t === document.body) return false; const tag = String(t.tagName || "").toUpperCase(); return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || tag === "OPTION" || !!t.isContentEditable; }; // Type-ahead finder for one kind of thing (deal / journal / acct / vehicle / customer). // value key of the item the report is showing now onSelect(item, {auto}) item.key / item.ref // autoExact typing an exact key (e.g. an account number) selects it by itself (no click needed) // onTop (topKey, query) — lets a report preview the best match while typing (journal document) const CrFinder = ({ kind, value, onSelect, placeholder, primary, autoExact, onTop, hint, inputProps, width, noStep, extra }) => { const cfg = CR_KINDS[kind]; const [q, setQ] = React.useState(""); const [dq, setDq] = React.useState(""); const [open, setOpen] = React.useState(false); const [hi, setHi] = React.useState(0); const [limit, setLimit] = React.useState(CR_PAGE); const [, setRecTick] = React.useState(0); const inRef = React.useRef(null), listRef = React.useRef(null), tRef = React.useRef(null); const idx = crGetIndex(kind); const res = React.useMemo(() => crSearch(idx, dq), [idx, dq]); const list = res.list; const recents = dq.trim() ? [] : crRecentGet(kind).map(k => idx.byKey.get(k)).filter(Boolean); const shown = list.slice(0, limit); const rows = [...recents.map(it => ({ it, sec: "recent" })), ...shown.map(it => ({ it, sec: "all" }))]; const safeHi = Math.min(hi, Math.max(0, rows.length - 1)); const cur = value ? idx.byKey.get(String(value)) : null; const pos = value ? list.findIndex(r => r.key === String(value)) : -1; const uid = React.useMemo(() => "cr-fd-" + kind + "-" + Math.random().toString(36).slice(2, 7), [kind]); React.useEffect(() => () => clearTimeout(tRef.current), []); React.useEffect(() => { if (onTop) onTop(dq.trim() && list.length && res.toks.length ? list[0].key : "", dq); }, [res]); React.useEffect(() => { if (!autoExact) return; const t = dq.trim(); if (!t) return; const it = idx.byUp.get(t.toUpperCase()); if (it && String(it.key) !== String(value)) onSelect && onSelect(it, { auto: true }); }, [dq, idx]); React.useEffect(() => { if (!open) return; const el = listRef.current && listRef.current.querySelector('[data-active="1"]'); if (el && el.scrollIntoView) el.scrollIntoView({ block: "nearest" }); }, [safeHi, open, limit]); const onChange = (e) => { const v = e.target.value; setQ(v); setOpen(true); clearTimeout(tRef.current); if (!v.trim()) { setDq(""); setHi(0); setLimit(CR_PAGE); return; } tRef.current = setTimeout(() => { setDq(v); setHi(0); setLimit(CR_PAGE); }, CR_DEBOUNCE); }; const openList = () => { setOpen(true); if (!dq.trim() && value) { const i = rows.findIndex(x => x.sec === "all" && x.it.key === String(value)); if (i >= 0) setHi(i); } }; const choose = (it, fromStep) => { crRecentPush(kind, it.key); setRecTick(t => t + 1); onSelect && onSelect(it, {}); if (!fromStep) setOpen(false); }; const clear = () => { clearTimeout(tRef.current); setQ(""); setDq(""); setHi(0); setLimit(CR_PAGE); setOpen(true); if (inRef.current) inRef.current.focus(); }; const step = (dir) => { if (!list.length) return; const n = pos < 0 ? (dir > 0 ? 0 : list.length - 1) : pos + dir; if (n < 0 || n >= list.length) return; choose(list[n], true); }; const onKey = (e) => { if (e.key === "ArrowDown") { e.preventDefault(); if (!open) { openList(); return; } setHi(Math.min(rows.length - 1, safeHi + 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); if (!open) { openList(); return; } setHi(Math.max(0, safeHi - 1)); } else if (e.key === "PageDown") { e.preventDefault(); setOpen(true); setHi(Math.min(rows.length - 1, safeHi + 8)); } else if (e.key === "PageUp") { e.preventDefault(); setHi(Math.max(0, safeHi - 8)); } else if (e.key === "Enter") { e.preventDefault(); if (q !== dq) { clearTimeout(tRef.current); setDq(q); setHi(0); const r = crSearch(idx, q); if (r.list[0]) choose(r.list[0]); return; } if (!open) { openList(); return; } if (rows[safeHi]) choose(rows[safeHi].it); } else if (e.key === "Escape") { if (open) { e.preventDefault(); setOpen(false); } else if (q) { e.preventDefault(); clear(); setOpen(false); } } else if (e.key === "Tab") setOpen(false); }; const canPrev = list.length > 0 && (pos < 0 || pos > 0), canNext = list.length > 0 && (pos < 0 || pos < list.length - 1); let lastSec = ""; return (
setOpen(true)} onKeyDown={onKey} onBlur={() => setTimeout(() => setOpen(false), 160)} placeholder={placeholder} role="combobox" aria-expanded={open ? "true" : "false"} aria-autocomplete="list" aria-controls={uid} autoComplete="off" spellCheck={false} {...(inputProps || {})} /> {q ? : /} {open && (
e.preventDefault()} data-cr-list={kind} data-cr-q={dq}>
{dq.trim() ? <>{list.length} of {idx.items.length} {cfg.noun} match : <>All {idx.items.length} {cfg.noun}}↑ ↓ move · Enter open · Esc close
{rows.length === 0 &&
{dq.trim() ? <>Nothing matches "{q}". Try fewer or shorter words — case, punctuation and accents do not matter. : <>There are no {cfg.noun} to pick yet.}
} {rows.map((x, i) => { const hdr = x.sec !== lastSec ? (x.sec === "recent" ? "Recent picks" : (recents.length ? "All " + cfg.noun : (dq.trim() ? "Matches" : null))) : null; lastSec = x.sec; return ( {hdr ?
{hdr}
: null} { if ((e.movementX || e.movementY) && i !== safeHi) setHi(i); }} domId={uid + "-" + i} />
); })} {list.length > shown.length && (
{list.length - shown.length} more - keep typing to narrow the list
)}
)}
{!noStep && <> } {(dq.trim() ? list.length : idx.items.length)} of {idx.items.length} {!noStep && pos >= 0 && list.length > 0 && #{pos + 1} in list} {cur ? {cfg.view(cur).title}{kind === "acct" ? " bal " + crAmt(cur.bal) : ""} : null} {typeof extra === "function" ? extra({ list, dq, res }) : (extra || null)}
{hint ?
{hint}
: null}
); }; // "Find anything": one box above the tabs. Results grouped by kind; clicking one calls onPick(kind, item). const CrFindAnything = ({ onPick }) => { const [q, setQ] = React.useState(""); const [dq, setDq] = React.useState(""); const [open, setOpen] = React.useState(false); const [hi, setHi] = React.useState(0); const inRef = React.useRef(null), listRef = React.useRef(null), tRef = React.useRef(null); const sig = open && dq.trim() ? CR_ANY_ORDER.map(k => CR_KINDS[k].ver()).join("#") : ""; const groups = React.useMemo(() => { if (!dq.trim()) return []; return CR_ANY_ORDER.map(k => { const r = crSearch(crGetIndex(k), dq); return { kind: k, cfg: CR_KINDS[k], total: r.list.length, items: r.list.slice(0, CR_ANY_PER), hl: r.hl }; }).filter(g => g.total); }, [dq, sig]); const flat = []; groups.forEach(g => g.items.forEach(it => flat.push({ g, it }))); const safeHi = Math.min(hi, Math.max(0, flat.length - 1)); React.useEffect(() => () => clearTimeout(tRef.current), []); React.useEffect(() => { if (!open) return; const el = listRef.current && listRef.current.querySelector('[data-active="1"]'); if (el && el.scrollIntoView) el.scrollIntoView({ block: "nearest" }); }, [safeHi, open]); const onChange = (e) => { const v = e.target.value; setQ(v); setOpen(true); clearTimeout(tRef.current); if (!v.trim()) { setDq(""); setHi(0); return; } tRef.current = setTimeout(() => { setDq(v); setHi(0); }, CR_DEBOUNCE); }; const pick = (g, it) => { setOpen(false); setQ(""); setDq(""); if (inRef.current) inRef.current.blur(); onPick(g.kind, it); }; const clear = () => { clearTimeout(tRef.current); setQ(""); setDq(""); setHi(0); if (inRef.current) inRef.current.focus(); }; const onKey = (e) => { if (e.key === "ArrowDown") { e.preventDefault(); setOpen(true); setHi(Math.min(flat.length - 1, safeHi + 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setHi(Math.max(0, safeHi - 1)); } else if (e.key === "Enter") { e.preventDefault(); if (q !== dq) { clearTimeout(tRef.current); const gs = CR_ANY_ORDER.map(k => ({ kind: k, r: crSearch(crGetIndex(k), q) })).filter(x => x.r.list.length); if (gs[0]) pick({ kind: gs[0].kind }, gs[0].r.list[0]); return; } if (flat[safeHi]) pick(flat[safeHi].g, flat[safeHi].it); } else if (e.key === "Escape") { if (open) { e.preventDefault(); setOpen(false); } else if (q) { e.preventDefault(); clear(); } } else if (e.key === "Tab") setOpen(false); }; let n = -1; return (
Find anything
setOpen(true)} onClick={() => setOpen(true)} onKeyDown={onKey} onBlur={() => setTimeout(() => setOpen(false), 160)} placeholder="Deal #, buyer, VIN, phone, account #, amount, stock, journal document…" role="combobox" aria-expanded={open ? "true" : "false"} autoComplete="off" spellCheck={false} /> {q ? : Ctrl K} {open && dq.trim() && (
e.preventDefault()} data-cr-any-list="1" data-cr-q={dq}>
{groups.length ? <>{groups.reduce((s, g) => s + g.total, 0)} matches in {groups.length} place{groups.length === 1 ? "" : "s"} : "No matches"}↑ ↓ move · Enter open · Esc close
{!groups.length &&
Nothing found for "{dq}". Try fewer or shorter words.
} {groups.map(g => (
{g.cfg.label} ({g.total}){g.kind === "deal" ? "opens Deal Recap" : g.kind === "journal" ? "opens Journal Document" : g.kind === "acct" ? "opens GL Account Detail" : g.kind === "vehicle" ? "opens Vehicle Master" : "opens their latest deal"}
{g.items.map(it => { n++; const i = n; return pick(g, it)} onHover={(e) => { if ((e.movementX || e.movementY) && i !== safeHi) setHi(i); }} domId={"cr-any-" + i} />; })} {g.total > g.items.length &&
{g.total - g.items.length} more - keep typing
}
))}
)} {open && !dq.trim() && (
Type to search deals, journal documents, accounts, vehicles and customers at once. Pick a result to open it in the right report.
)}
); }; /* ═══ SCREEN ══════════════════════════════════════════════════════════════ */ const CR_TABS = [ { k: "recap", label: "Deal Recap" }, { k: "posting", label: "Deal Posting Worksheet" }, { k: "journal", label: "Journal Document" }, { k: "fisetup", label: "F&I → GL Setup" }, { k: "coa", label: "Chart of Accounts" }, { k: "sched", label: "Aged Schedules" }, { k: "detail", label: "GL Account Detail" }, { k: "history", label: "Account History" }, { k: "gm", label: "GM Operating Report p.1" }, { k: "vehicles", label: "Vehicle Master Report" }, { k: "tb", label: "Trial Balance" }, ]; const crInp = { padding: "5px 8px", border: "1px solid #C8CCD2", borderRadius: 4, fontSize: 12.5, boxSizing: "border-box" }; const crLbl = { fontSize: 11, color: "#555", fontWeight: 700, display: "block", marginBottom: 2 }; const crUseDebounced = (v, ms) => { const [d, setD] = React.useState(v); React.useEffect(() => { const t = setTimeout(() => setD(v), v === "" ? 0 : ms); return () => clearTimeout(t); }, [v]); return d; }; // a plain filter box with a clear (x) button — used where a report is filtered in place const CrFilterBox = ({ value, onChange, placeholder, width, attr, title }) => ( onChange(e.target.value)} placeholder={placeholder} title={title} autoComplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" name="k2b-find-cr-filter" style={{ ...crInp, width: width || 260, paddingRight: 24 }} {...(attr || {})} /> {value ? : null} ); const CrRecapEditor = ({ deal, onSaved }) => { const m = crRecapModel(deal); const [r, setR] = React.useState(() => ({ ...(deal.recap || {}), adds: (deal.recap && deal.recap.adds) || [], comm: m.comm.map(c => ({ name: c.name, base: c.base, pct: c.pct, flat: c.flat, mini: c.mini })) })); React.useEffect(() => { const mm = crRecapModel(deal); setR({ ...(deal.recap || {}), adds: (deal.recap && deal.recap.adds) || [], comm: mm.comm.map(c => ({ name: c.name, base: c.base, pct: c.pct, flat: c.flat, mini: c.mini })) }); }, [deal.id]); const canEdit = !!(crD().can && (crD().can("desk") || crD().can("fi") || crD().can("acct"))) || !!(crD().isAdmin && crD().isAdmin()); const set = (k, v) => setR(x => ({ ...x, [k]: v })); const numF = (k, label, ph) => ( ); const save = () => { const clean = { ...r }; ["invoicePrice", "holdback", "pack", "sfd", "glRepairs", "totalRepairs", "cashStated", "buyRate", "reserve", "clPrem", "clCost", "ahPrem", "ahCost", "miscFi", "miscDealer", "trade2Acv", "trade2Allow"].forEach(k => { if (clean[k] === undefined || clean[k] === "") delete clean[k]; else clean[k] = crNum(clean[k]); }); clean.adds = (clean.adds || []).filter(a => a.label || crNum(a.amount)).map(a => ({ label: String(a.label || "").slice(0, 30), amount: crNum(a.amount) })); clean.comm = (clean.comm || []).map(c => ({ name: String(c.name || "").toUpperCase(), base: c.base === "fi" ? "fi" : "front", pct: crNum(c.pct), flat: crNum(c.flat), mini: crNum(c.mini) })); clean.comm.forEach(c => { if (c.name) crSaveCommDefault(c.name, c); }); const res = crD().updateDeal ? crD().updateDeal(deal.id, { recap: clean }) : { ok: false }; if (crD().logAudit) crD().logAudit("Deal Recap Updated", deal.id); window.k2bToast && window.k2bToast(res && res.ok !== false ? "Recap saved." : "Could not save the recap.", res && res.ok !== false ? "ok" : "warn"); onSaved && onSaved(); }; const est = m.reserveEst; return (
Recap inputs {canEdit ? "" : "(read only)"}
Blank = taken from the deal / vehicle. These only change the recap, never the accounting.
{numF("invoicePrice", "Invoice price", m.invoice)}{numF("holdback", "Hold back & advertising", 0)} {numF("pack", "Pack lot", m.pack)}{numF("sfd", "Special finance discount", 0)} {numF("glRepairs", "GL repairs & adjustments", m.glRepairs)}{numF("totalRepairs", "Total repairs (other)", 0)} {numF("cashStated", "Cash stated", m.cash)}{numF("buyRate", "Buy rate %", m.contract)} {numF("reserve", "Finance reserve $", (deal.reserve || "") + (est ? ` (est. ${crAmt(est)})` : ""))}{numF("miscFi", "Misc F&I profit", 0)} {numF("clPrem", "Credit life premium", 0)}{numF("clCost", "Credit life cost", 0)} {numF("ahPrem", "Disability premium", 0)}{numF("ahCost", "Disability cost", 0)} {numF("miscDealer", "Misc dealer profit", 0)}{numF("fiMgr", "F&I manager", "")}
Added cost lines (e.g. DPA, Service A/S, tires)
{(r.adds || []).map((a, i) => (
set("adds", r.adds.map((x, j) => j === i ? { ...x, label: e.target.value } : x))} style={{ ...crInp, flex: 2 }} /> set("adds", r.adds.map((x, j) => j === i ? { ...x, amount: e.target.value } : x))} style={{ ...crInp, flex: 1 }} /> {canEdit && }
))} {canEdit && }
Commissions (amount = gross × % + flat, never below the mini)
NameBased on%Flat $Mini $
{(r.comm || []).map((c, i) => (
{["name"].map(k => set("comm", r.comm.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} style={{ ...crInp, width: "100%" }} />)} {["pct", "flat", "mini"].map(k => set("comm", r.comm.map((x, j) => j === i ? { ...x, [k]: e.target.value } : x))} style={{ ...crInp, width: "100%" }} />)}
))} {canEdit &&
}
); }; const crCustDeal = (c) => { const dig = String(c.phone || c.cell || "").replace(/\D/g, ""); const keys = [String(c.custNum || "")].concat(Array.isArray(c.aliases) ? c.aliases.map(String) : []).filter(Boolean); // RC30 (cn): a deal may still name an old number const nm = [crFold([c.firstName, c.lastName].filter(Boolean).join(" ")), crFold([c.lastName, c.firstName].filter(Boolean).join(", ")), crFold([c.lastName, c.firstName].filter(Boolean).join(" "))].map(s => s.replace(/[^a-z0-9]+/g, "")); const hits = crDeals().filter(d => { if (!d) return false; if (d.custNum && keys.indexOf(String(d.custNum)) >= 0) return true; const pd = String(d.phone || "").replace(/\D/g, ""); if (dig.length >= 7 && pd && pd.endsWith(dig.slice(-10))) return true; const b = crFold(d.buyer || "").replace(/[^a-z0-9]+/g, ""); return !!b && nm.indexOf(b) >= 0 && nm[0] !== ""; }); hits.sort((a, b) => (crDate(crDealDate(b)) || 0) - (crDate(crDealDate(a)) || 0)); return hits[0] || null; }; const ClassicReports = ({ onClose, go }) => { const [tab, setTab] = React.useState(() => { try { const t = localStorage.getItem("k2b-cr-tab"); return CR_TABS.some(x => x.k === t) ? t : "recap"; } catch (e) { return "recap"; } }); const [, setTick] = React.useState(0); React.useEffect(() => { const D = crD(); if (!D.subscribe) return; let t = null; const un = D.subscribe(() => { clearTimeout(t); t = setTimeout(() => setTick(x => x + 1), 400); }); return () => { clearTimeout(t); un && un(); }; }, []); React.useEffect(() => { try { localStorage.setItem("k2b-cr-tab", tab); } catch (e) {} }, [tab]); const deals = crDeals(); const [dealId, setDealId] = React.useState(() => { try { return localStorage.getItem("jb_deal_focus") || ""; } catch (e) { return ""; } }); // default = the most recently delivered deal (same order as the finder's list), else the first deal const dealIdx = (tab === "recap" || tab === "posting") && !deals.some(d => d.id === dealId) ? crGetIndex("deal") : null; const deal = deals.find(d => d.id === dealId) || (dealIdx && dealIdx.items[0] && dealIdx.items[0].ref) || deals[0] || null; const [jeId, setJeId] = React.useState(""); // the document the user picked (pinned) const [jePin, setJePin] = React.useState(false); const [jeTop, setJeTop] = React.useState(""); // best match while typing (previewed until a pick) const [acct, setAcct] = React.useState("261"); const [ctl, setCtl] = React.useState(""); const [amt, setAmt] = React.useState(""); const [asOf, setAsOf] = React.useState(""); const [coaQ, setCoaQ] = React.useState(""); const [vsort, setVsort] = React.useState("acct"); const [vq, setVq] = React.useState(""); const [sq, setSq] = React.useState(""); const [tbMonth, setTbMonth] = React.useState(""); const [tbAsOf, setTbAsOf] = React.useState(""); const [tbQ, setTbQ] = React.useState(""); const [year, setYear] = React.useState(String(new Date().getFullYear())); const dvq = crUseDebounced(vq, CR_DEBOUNCE), dsq = crUseDebounced(sq, CR_DEBOUNCE), dtbq = crUseDebounced(tbQ, CR_DEBOUNCE); const jes = crJEs(); const jeSel = jePin ? jeId : (jeTop || jeId); const je = (jeSel && jes.find(j => String(j.id) === String(jeSel))) || jes[0] || null; const tbm = tab === "tb" ? crTbMonths() : null; const tbMonthEff = tbMonth || (tbm ? (tbm.open || tbm.list[0] || "") : ""); // build the indexes one at a time in the background so the first keystroke never waits for them; // leaving the screen drops them again (they can be tens of MB with 10,000 records) React.useEffect(() => { let i = 0, t = null; const kinds = CR_ANY_ORDER; const run = () => { if (i >= kinds.length) return; try { crGetIndex(kinds[i++]); } catch (e) {} t = setTimeout(run, 40); }; t = setTimeout(run, 500); return () => { clearTimeout(t); Object.keys(CR_IDX).forEach(k => { delete CR_IDX[k]; }); }; }, []); // "/" jumps to this report's search box (or Find anything); Ctrl+K jumps to Find anything React.useEffect(() => { const onKey = (e) => { if (e.defaultPrevented) return; const modal = (() => { try { return !!document.querySelector("[data-k2b-modal]"); } catch (x) { return false; } })(); if (e.key === "/" && !e.ctrlKey && !e.metaKey && !e.altKey) { if (crFromField(e) || modal) return; const el = document.querySelector("[data-cr-primary] input") || document.querySelector("[data-cr-any] input"); if (el) { e.preventDefault(); el.focus(); if (el.select) el.select(); } } else if ((e.ctrlKey || e.metaKey) && String(e.key).toLowerCase() === "k" && !modal) { const el = document.querySelector("[data-cr-any] input"); if (el) { e.preventDefault(); el.focus(); if (el.select) el.select(); } } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); const toast = (m, k) => { try { window.k2bToast && window.k2bToast(m, k || "info"); } catch (e) {} }; // Find anything → the right report tab with that item selected const pickAny = (kind, it) => { if (kind === "deal") { setDealId(it.key); if (tab !== "posting") setTab("recap"); } else if (kind === "journal") { setJeId(it.key); setJePin(true); setJeTop(""); setTab("journal"); } else if (kind === "acct") { setAcct(it.key); if (tab !== "sched" && tab !== "history") setTab("detail"); } else if (kind === "vehicle") { const u = it.ref; if (crVehOnReport(u)) { setVq(String(u.stock)); setTab("vehicles"); } else { const dl = crDeals().find(d => d && String(d.stock || "").toUpperCase() === String(u.stock).toUpperCase()); if (dl) { setDealId(dl.id); setTab("recap"); toast(`${u.stock} is sold - showing deal ${dl.id}.`); } else { setVq(String(u.stock)); setTab("vehicles"); toast(`${u.stock} is not on the Vehicle Master Report (status: ${u.status || "none"}).`); } } } else if (kind === "customer") { const dl = crCustDeal(it.ref); if (dl) { setDealId(dl.id); setTab("recap"); } else toast(`${[it.ref.firstName, it.ref.lastName].filter(Boolean).join(" ") || it.key} has no deals yet.`); } }; let title = (CR_TABS.find(t => t.k === tab) || CR_TABS[0]).label, html = "", landscape = false; try { if (tab === "recap") html = deal ? crRecapHtml(crRecapModel(deal)) : `
No deals yet.
`; else if (tab === "posting") html = deal ? crPostingHtml(deal) : `
No deals yet.
`; else if (tab === "journal") html = crJournalHtml(je); else if (tab === "fisetup") html = crFiSetupHtml(); else if (tab === "coa") html = crCoaHtml(coaQ); else if (tab === "sched") html = crScheduleHtml(acct, asOf ? crDate(asOf) : null, dsq); else if (tab === "detail") html = crDetailHtml(acct, ctl, amt); else if (tab === "history") html = crHistoryHtml(acct, 24); else if (tab === "gm") { html = crGmHtml(year); landscape = true; } else if (tab === "vehicles") { html = crVehHtml(vsort, dvq); landscape = true; } else if (tab === "tb") html = crTbHtml(tbMonthEff, tbAsOf, dtbq); } catch (e) { html = `
This report could not be built: ${crEsc(e.message)}
`; } const dealHint = "Search: deal # · invoice # · buyer / co-buyer · stock # · VIN (any part) · phone (any punctuation) · salesperson · lender · vehicle · delivery date (9/29, 09-29-26, 2026-09-29, sept) · amount (23995 or 23,995.00) · stage (posted, funded, desked). Several words = all must match."; const dealQuick = (f) => { const opts = f.list.slice(0, CR_PAGE), cur = deal ? deal.id : ""; const have = opts.some(it => it.key === cur); return ( ); }; const acctFinder = setAcct(it.key)} placeholder="Find account: number or name (e.g. 261)" inputProps={{ "data-cr-acct": "1" }} hint="Search: account # (prefix) · name words · alter number · type. Typing an exact account number opens it." />; return (
{CR_TABS.map((t, i) => (
setTab(t.k)} style={{ padding: "8px 14px", cursor: "pointer", fontSize: 13, fontWeight: tab === t.k ? 700 : 500, background: tab === t.k ? "var(--jb-blue-tint, #E5EFFA)" : "transparent", borderLeft: tab === t.k ? "3px solid var(--jb-navy, #1B66A8)" : "3px solid transparent" }}>{i + 1}. {t.label}
))}
Same layouts as the old DMS printouts, built from live K2B data. Press / to search.
{(tab === "recap" || tab === "posting") && setDealId(it.key)} placeholder="Search deals… ( / )" hint={dealHint} extra={dealQuick} />} {tab === "journal" && { setJeId(it.key); setJePin(true); }} onTop={(key, dq) => { if (dq.trim()) { setJeTop(key); setJePin(false); } else setJeTop(""); }} placeholder="Find document / deal / stock" hint="Search: document # · deal # · entry id · stock / control # · description words · ANY account # in its lines · exact line amount (1350.63) · journal # · date (9/29, 2026-09-29, sept). While you type the best match is shown; Enter or a click keeps it." />} {tab === "coa" && } {(tab === "sched" || tab === "detail" || tab === "history") && acctFinder} {tab === "sched" && } {tab === "sched" && {["205", "220", "220A", "261", "263", "240", "241"].map(a => )}} {tab === "sched" && } {tab === "detail" && setCtl(e.target.value)} placeholder="F2 Find control" style={{ ...crInp, width: 150, marginRight: 8 }} /> setAmt(e.target.value)} placeholder="F3 Find amount" style={{ ...crInp, width: 130 }} />} {tab === "gm" && } {tab === "vehicles" && } {tab === "vehicles" && } {tab === "tb" && <> }
{tab === "tb" && window.ImpTbSchedules && } {/* RC25 (imp): which inventory accounts tie to a schedule, last reconciled when */}
{ const t = e.target.closest && e.target.closest("tr[data-je]"); if (t) { setJeId(t.getAttribute("data-je")); setJePin(true); setJeTop(""); setTab("journal"); return; } const a = e.target.closest && e.target.closest("tr[data-tb-acct]"); if (a) { setAcct(a.getAttribute("data-tb-acct")); setCtl(""); setAmt(""); setTab("detail"); } }} dangerouslySetInnerHTML={{ __html: html }} /> {tab === "recap" && deal &&
setTick(x => x + 1)} />
}
ESC-Close
); }; Object.assign(window, { ClassicReports, crRecapModel, crScheduleData, crSearch, crGetIndex, crParseTokens, crTbModel, crTbHtml, crVehHtml, crVehRows });