/* eslint-disable */ /* © 2026 K2B NETWORK SOLUTIONS LLC. All rights reserved. Proprietary and confidential. Unauthorized copying, distribution, or use of this file, via any medium, is strictly prohibited. Original work product. */ // JBREWS DMS — Upload Tool / Import-from-Old-DMS. // Two modes: // • Auto Upload — nightly manufacturer/lender feeds (live log) // • Import — bring data over from your previous DMS (Reynolds, CDK, // DealerTrack, Autosoft, K2B, etc.). Step-through wizard. // RC35 (brd): the store this page is in (its number and print name) - no store is written in this file const upStoreId = () => (window.K2BStore && window.K2BStore.pageId()) || ""; const upStoreName = () => (window.K2BStore ? window.K2BStore.branding().printName : ""); const UPLOAD_LOG = [ { ts: "05/11/2026 03:00:12", level: "INFO", msg: "Auto Upload started by scheduler (NIGHTLY)" }, { ts: "05/11/2026 03:00:13", level: "INFO", get msg() { return "Logged on as THIS-PC / Store " + upStoreId(); } }, { ts: "05/11/2026 03:00:15", level: "INFO", msg: "Export GMDS-VI (Vehicle Inventory) — 62 rows" }, { ts: "05/11/2026 03:00:18", level: "OK", msg: "GMDS-VI accepted by gmds.gm.com (ref 26-2026-05-7139)" }, { ts: "05/11/2026 03:00:19", level: "INFO", msg: "Export GMDS-VS (Vehicle Sales) — 14 rows" }, { ts: "05/11/2026 03:00:22", level: "WARN", msg: "GMDS-VS accepted with 3 records flagged (missing F&I detail)" }, { ts: "05/11/2026 03:00:23", level: "INFO", msg: "Export GMDS-PI (Parts Inventory) — 1,248 rows" }, { ts: "05/11/2026 03:00:31", level: "OK", msg: "GMDS-PI accepted by gmds.gm.com (ref 26-2026-05-7140)" }, { ts: "05/11/2026 03:00:32", level: "INFO", msg: "Lender feed EXETER FINANCE — 1 deal" }, { ts: "05/11/2026 03:00:34", level: "OK", msg: "EXETER FINANCE accepted (deal 14614, contract ACK)" }, { ts: "05/11/2026 03:00:35", level: "INFO", msg: "Lender feed CHASE AUTO — 0 deals" }, { ts: "05/11/2026 03:00:35", level: "INFO", msg: "Lender feed ALLY FINANCIAL — 0 deals" }, { ts: "05/11/2026 03:00:36", level: "INFO", msg: "Auto Upload finished — 7 feeds, 1,637 rows, 1 warning" }, ]; const IMPORT_SOURCES = [ "Legacy Finance Professional (W26 or earlier)", "Reynolds & Reynolds — ERA / ERA-IGNITE", "CDK Global — Drive", "DealerTrack DMS", "Autosoft Flex DMS", "PBS Systems", "Dominion VUE", "Tekion ARC", "Custom CSV / Fixed-width export", ]; const IMPORT_TABLES = [ { key: "deals", name: "Open & closed deals", rows: 14612, status: "ready" }, { key: "customers", name: "Customers / co-buyers", rows: 28471, status: "ready" }, { key: "inv", name: "Vehicle inventory", rows: 162, status: "ready" }, { key: "parts", name: "Parts master + inventory", rows: 47129, status: "ready" }, { key: "ros", name: "Service repair orders", rows: 9871, status: "ready" }, { key: "gl", name: "General Ledger (3 yrs)", rows:118402, status: "ready" }, { key: "ap", name: "Accounts Payable", rows: 6204, status: "ready" }, { key: "ar", name: "Accounts Receivable", rows: 3118, status: "ready" }, { key: "payroll", name: "Payroll history (2 yrs)", rows: 14987, status: "ready" }, { key: "schedules", name: "Schedule sub-ledgers", rows: 9824, status: "warn", note: "12 rows missing control acct" }, ]; const UploadTool = ({ onClose, go }) => { const [mode, setMode] = React.useState(() => { // Allow other screens to deep-link to a specific tab via localStorage const m = localStorage.getItem("jbrews-upload-mode"); if (m) { localStorage.removeItem("jbrews-upload-mode"); return m; } return "import"; // "import" | "oldreports" | "history" | "restore" | "export" | "auto" }); const [openMenu, setOpenMenu] = React.useState(null); // "Review & import" on an Old reports card hands that list to the normal import wizard (opened at the preview) const [preload, setPreload] = React.useState(null); // Backup-restore wizard state (the legacy .json / .jbz path) const [step, setStep] = React.useState(1); const [source, setSource] = React.useState(IMPORT_SOURCES[0]); const [path, setPath] = React.useState("J:\\OLDDMS\\EXPORT\\"); const [picked, setPicked] = React.useState(() => Object.fromEntries(IMPORT_TABLES.map(t => [t.key, true])) ); const MENU = [["Import", "import"], ["Old reports", "oldreports"], ["Import history", "history"], ["Restore backup", "restore"], ["Export", "export"], ["Auto Upload", "auto"], ["Backup", null], ["About", null], ["Close", null]]; const flowMode = mode === "import" || mode === "history" || mode === "oldreports"; const reviewDataset = (ds, meta) => { setPreload({ dataset: ds, fileName: (meta && meta.fileName) || "Old reports packet", nonce: Date.now() + Math.random() }); setMode("import"); }; return (
{}} onMax={() => {}} />
{MENU.map(([m, key]) => { const isActive = key && key === mode; return ( { if (m === "Close") onClose(); else if (key) { if (key === "import" && mode !== "import") setPreload(null); setMode(key); setOpenMenu(null); } else if (m === "Backup") window.k2bToast && window.k2bToast( "Backup started — writing to J:\\K2B\\BACKUP\\" + upStoreId() + "-snapshot.bak", "ok"); else if (m === "About") setOpenMenu(openMenu === m ? null : m); }} style={{ background: (openMenu === m || isActive) ? "#145FA0" : undefined, borderBottom: isActive ? "2px solid #FFFFFF" : "2px solid transparent", }}> {m} ); })}
{mode === "oldreports" ? (window.OldReportsScreen ? setMode("history")} go={go} /> :
OldReports.jsx.txt is not loaded.
) : flowMode ? setMode(v === "history" ? "history" : "import")} onRestore={() => setMode("restore")} onClose={onClose} preload={preload} onBackToPacket={() => setMode("oldreports")} go={go} /> : mode === "restore" ? : mode === "export" ? : } {openMenu === "About" && ( setOpenMenu(null)}>
Upload & Import Tool v2.0.0
Imports into the General Ledger (chart, journal entries, trial balance), Inventory, Deals, Aged schedules, Customers, Vendors and the Check register with a column-mapping preview, reads packets of old scanned reports (Old reports), restores DMS backups, and runs the nightly manufacturer/lender feeds.
© {window.K2BStore ? window.K2BStore.branding().dba : ""} — internal use
)}
); }; /* ─── Import (one path for every destination) ───────────────────────── 1 Destination → 2 File → 3 Map columns → 4 Preview (validation + dry run) → 5 Import (explicit final action). Nothing is saved before step 5. All logic is in ImportCore (DMS.importPlan / DMS.importCommit); this is the UI. */ const IMP_STEPS = [[1, "Destination"], [2, "File"], [3, "Map columns"], [4, "Preview"], [5, "Import"]]; // Destination cards: one per group of DMS.IMPORT_GROUPS (order and blurb come from ImportCore); a group with several // destinations (General Ledger: chart / journal / trial balance / aged schedules) shows them as pills. const IMP_ICONS = { gl: "📒", inventory: "🚗", deals: "🤝", customers: "👤", vendors: "🏢", checks: "🧾", service: "🔧", parts: "📦" }; // RC25 (imp): + service / parts const IMP_SUB_LABEL = { glChart: "Chart of accounts", glJournal: "Journal entries", glTrial: "Trial balance (opening balances)", schedule: "Aged schedules (261 / 220 / 220A)", inventory: "Units in stock (vehicle master)", vehicleMaster: "VIN list (lookup only)" }; const impCards = () => { const D = window.DMS; const G = (D && D.IMPORT_GROUPS) || {}; const dests = D && D.importDests ? D.importDests() : []; return Object.keys(G).map(k => ({ key: k, icon: IMP_ICONS[k] || "📄", title: G[k].label, blurb: G[k].blurb, dests: dests.filter(d => d.group === k).map(d => d.key) })).filter(c => c.dests.length); }; // A "posts to the General Ledger" destination needs the extra confirm; everything else is records only. const IMP_POSTS_GL = { glJournal: true, glTrial: true }; // Which columns of the old report to show next to the record in the preview, per destination (importer field keys) const IMP_PV_COLS = { glChart: ["alt", "dc", "costAcct", "invAcct"], glTrial: ["mtd", "ytd"], schedule: ["days", "balance"], inventory: ["invoiceDate", "invoiceCost", "repairs", "glCost"], deals: ["date", "buyer", "vehicle", "salePrice", "netNet"], roHistory: ["date", "flag", "laborSale", "partsSale", "total", "customerPay", "otherPay"], partsMaster: ["bin", "onHand", "cost", "list"], vehicleMaster: ["year", "make", "model"] }; // RC25 (imp) // "What to check next" after an import: Classic Reports tab, plain words const IMP_NEXT = { glChart: { tab: "coa", label: "Classic Reports › Chart of Accounts", why: "Compare the alter numbers, D/C flags and cost / inventory accounts with the printed chart." }, glTrial: { tab: "tb", label: "Classic Reports › Trial Balance (report 11)", why: "Pick the month of the as-of date: every account should show the printed year-to-date amount and the report should end at 0.00 / 0.00." }, glJournal: { tab: "journal", label: "Classic Reports › Journal Document", why: "Look up one of the imported document numbers and compare its lines with the printout." }, inventory: { tab: "vehicles", label: "Classic Reports › Vehicle Master Report", why: "Each inventory account's subtotal (units and GL cost) should equal the printed subtotal; the total should be the printed Total Cars." }, schedule: { tab: "sched", label: "Classic Reports › Aged Schedules", why: "The imported detail shows beside the ledger's balance; the FINAL TOTAL and count should equal the printed ones." }, deals: { tab: "recap", label: "Classic Reports › Deal Recap", why: "Open one imported deal: the recap should show the printed numbers, including net / net." }, // RC25 (imp) roHistory: { route: "servicedept", label: "Service › Old ROs", why: "Look up one of the imported repair orders by its number or VIN: the totals, the payer and the job lines should match the old printout. The open ones are on the Dispatch Board." }, partsMaster: { route: "partsdept", label: "Parts Dept", why: "The parts list shows the imported parts with their bin, on-hand count and prices. Then tie the value on hand to the ledger on Accounting › Reconcile inventory to GL." }, vehicleMaster: { route: "servicedept", label: "Service › Old ROs", why: "The VIN list feeds the repair-order history lookup (year / make / model by VIN)." }, }; const IMP_RECONCILE_AFTER = { inventory: 1, partsMaster: 1, roHistory: 1 }; // RC25 (imp): offer Reconcile inventory to GL after these // open Classic Reports on a tab (deal focus optional). Uses the app's navigation when it was handed down, else the sidebar entry. const impOpenClassic = (go, tab, deal) => { try { if (tab) localStorage.setItem("k2b-cr-tab", tab); if (deal) localStorage.setItem("jb_deal_focus", String(deal)); } catch (e) {} if (typeof go === "function") { go("classicreports"); return; } const el = document.querySelector('.jb-sidebar [data-route="classicreports"]'); if (el) el.click(); else window.k2bToast && window.k2bToast("Open Accounting › Classic Reports from the menu.", "info", 4200); }; let impAppliedPreload = null; // nonce of the last Old reports hand-off the wizard applied (survives remounts) const IMP_ACT = { add: { label: "ADD", bg: "#D9F0DE", fg: "#176A38" }, update: { label: "UPDATE", bg: "#FFF0CC", fg: "#8A6400" }, skip: { label: "SKIP", bg: "#ECECEC", fg: "#555" }, reject: { label: "REJECT", bg: "#F3D9D9", fg: "#9A2A2A" }, }; const impBadge = (bg, fg, t, extra) => {t}; const impDownload = (name, text) => { const blob = new Blob([text], { type: "text/csv" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); }; const impFmtVal = (v) => (v === null || v === undefined || v === "" ? "blank" : typeof v === "number" ? v.toLocaleString("en-US", { maximumFractionDigits: 2 }) : typeof v === "boolean" ? (v ? "Yes" : "No") : String(v)); const impStamp = (iso) => { const d = new Date(iso); return isNaN(d) ? String(iso || "") : d.toLocaleString("en-US", { month: "2-digit", day: "2-digit", year: "numeric", hour: "numeric", minute: "2-digit" }); }; const impFileSlug = (s) => String(s || "import").replace(/\.[^.]+$/, "").replace(/[^A-Za-z0-9\-_]+/g, "-").slice(0, 40); /* ── import options (the plan options a destination declares in DMS.importDest(key).optionDefs) ───────────────────────── */ const impMoney = (n) => (n == null || n === "" || isNaN(+n) ? "—" : (+n < 0 ? "-$" : "$") + Math.abs(+n).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })); const impIso = (v) => { if (!v) return ""; if (v instanceof Date) return isNaN(v) ? "" : v.getFullYear() + "-" + String(v.getMonth() + 1).padStart(2, "0") + "-" + String(v.getDate()).padStart(2, "0"); const m = String(v).match(/^(\d{4})-(\d{2})-(\d{2})/); if (m) return m[0]; const u = String(v).match(/^(\d{1,2})\/(\d{1,2})\/(\d{2}|\d{4})$/); if (u) return (u[3].length === 2 ? "20" + u[3] : u[3]) + "-" + u[1].padStart(2, "0") + "-" + u[2].padStart(2, "0"); return ""; }; const impOptDefaults = (def) => { const o = {}; ((def && def.optionDefs) || []).forEach(od => { o[od.key] = od.type === "bool" ? !!od.default : od.default != null ? od.default : ""; }); return o; }; // only what the destination declares, only what is set — this is exactly what importPlan (and so the commit) receives const impOptsForPlan = (def, opts) => { const out = {}; ((def && def.optionDefs) || []).forEach(od => { const v = opts[od.key]; if (od.type === "bool") out[od.key] = !!v; else if (v !== "" && v != null) out[od.key] = v; }); return out; }; const ImpOptions = ({ def, opts, setOpts, plan, pre, mapped }) => { // the schedule account is only asked for when the file has no column for it const defs = ((def && def.optionDefs) || []).filter(od => !(od.key === "glAcct" && mapped && mapped.glAcct)); if (!defs.length) return null; const S = plan && plan.summary && !plan.error ? plan.summary : null; const set = (k, v) => setOpts(o => ({ ...o, [k]: v })); const lab = { fontWeight: 700, fontSize: 12, color: "var(--k2b-navy)" }; const hint = { fontSize: 11, color: "#666", lineHeight: 1.45 }; const weak = S && (S.asOfSource && /print|default|file|sheet|heading/i.test(String(S.asOfSource)) && !/option|you|balance sheet/i.test(String(S.asOfSource))); return (
Before you import — confirm these
{defs.map(od => { const v = opts[od.key]; if (od.type === "date") return (
set(od.key, e.target.value)} style={{ font: "inherit", padding: "3px 6px", borderColor: !v && od.required ? "#C0392B" : undefined }} /> {S && S.suggestedAsOf && impIso(S.suggestedAsOf) && impIso(S.suggestedAsOf) !== impIso(v) && } {pre && pre.asOfIso && impIso(v) === impIso(pre.asOfIso) && od.key === "asOf" ? {pre.asOfLevel === "warn" ? "⚠ only the date the report was printed" + (pre.asOfPrinted ? " (" + pre.asOfPrinted + ")" : "") + " was found in the packet — please check it" : "✓ read from " + (pre.asOfSource || "the old report") + " in the packet"}{pre.asOfPrinted && pre.asOfLevel !== "warn" ? " (the report itself was printed " + pre.asOfPrinted + ")" : ""} : S && S.asOfSource && {weak ? "⚠ guessed from the " : "✓ taken from "}{S.asOfSource}{weak ? " — please check it" : ""}}
{od.hint || ""} {def.key === "glTrial" ? "The opening balances are dated this day, so everything up to and including it is treated as already inside the trial balance." : ""}
); if (od.type === "select") return (
{(od.options || []).map(([val, txt]) => ( ))}
{S && S.period && od.key === "periodMode" && (
{S.period.asOfPeriodLabel} is {S.period.state === "closed" ? closed : S.period.state === "open" ? "open" : S.period.state === "ahead" ? "after the open month" : "before the open month"}; the open month is {S.period.openLabel}. The entry will be dated {S.period.postDate}. {S.period.state === "closed" ? " A closed month cannot take an entry: reopen it in Accounting › F6-Period End, or choose “the open month” above." : ""}
)}
); if (od.type === "bool") return ( ); if (od.options) return (
); return (
set(od.key, e.target.value)} style={{ font: "inherit", padding: "3px 6px", maxWidth: 320 }} />
); })}
); }; // red: nothing can be imported while any of these exist; amber: worth a look const ImpBlockers = ({ plan, setOpts }) => { const bl = (plan && plan.blockers) || []; if (!bl.length) return null; return (
{bl.map((b, i) => (
✕ {b.title}
{b.detail ?
{b.detail}
: null} {b.fix ?
What to do: {b.fix}
: null} {b.code === "unknown_accounts" && setOpts && }
))}
The import button stays off until these are fixed.
); }; const ImpWarnings = ({ plan }) => { const w = (plan && plan.warnings) || []; if (!w.length) return null; return (
{w.map((x, i) => (
⚠ {x.title}
{x.detail ?
{x.detail}
: null}
))}
); }; // the plan's own summary, in words (trial balance entry, schedule reconciliation, vehicle totals, ...) const ImpSummary = ({ plan, dest }) => { const s = plan && plan.summary; if (!s || plan.error) return null; const card = { background: "#F4F8FC", border: "1px solid #C9D6E3", borderRadius: 4, padding: "8px 11px", fontSize: 12, lineHeight: 1.5 }; const th = { padding: "3px 8px", textAlign: "left", background: "#F0F3F7", borderBottom: "1px solid #D6DEE7", fontWeight: 700, whiteSpace: "nowrap" }; const td = { padding: "3px 8px", borderTop: "1px solid #EEE" }; const num = { textAlign: "right", fontFamily: "'Courier Prime', monospace" }; const mono = { fontFamily: "'Courier Prime', monospace" }; if (s.kind === "roHistory") { // RC25 (imp) const b = s.byStatus || {}; return (
Repair orders in this file
{s.rows} repair orders: {b.closed} closed (history), {b.open} open (go on the service board), {b.held} held, {b.void} void · total of the non-void ROs {impMoney(s.total)}{s.payerSplit ? <> — customer pay {impMoney(s.customerPay)}, warranty / internal {impMoney(s.otherPay)} : null}{b.open ? <> · labor cost on the open ones {impMoney(s.openLaborCost)} (work in process) : null}
Job lines: {s.lines}{s.linesSheet ? ` from the sheet “${s.linesSheet}”` : ""} on {s.withLines} repair orders · sent in {s.batches} batch{s.batches === 1 ? "" : "es"} of {(window.DMS && window.DMS.IMPORT_BATCH) || 1000}.
{s.note}
); } if (s.kind === "roLines") return
{s.lines} job lines for {s.ros} repair orders · {s.note}
; if (s.kind === "parts") return
Parts in this file
{s.parts} parts · {s.onHandRows} with an on-hand count · value on hand (on-hand × cost) {impMoney(s.onHandValue)} · on-hand counts {s.applyOnHand ? will be set from the file : "are NOT applied"} · prices {s.updatePrices ? "updated" : "kept"}.
{s.note}
; if (s.kind === "vehicles") return
{s.rows} VINs · sent in {s.batches} batch{s.batches === 1 ? "" : "es"} · {s.note}
; if (s.kind === "trialBalance") { const e = s.entry, br = (s.breaks || []).filter(b => b.ok === false), nChecks = (s.checks || []).length, held = !!plan.blocked; const nLines = (e && e.lines || []).length || plan.toImport; return (
{held ? "The old report" : "What will be posted to the General Ledger"}
{held ?
Nothing will be posted until the red problems above are fixed.
: e ?
One balanced entry {e.ref} dated {e.date}, source “{e.src}” — {nLines} line{nLines === 1 ? "" : "s"}; debits {impMoney(e.debits)} = credits {impMoney(e.credits)} {Math.abs((e.debits || 0) - (e.credits || 0)) < 0.005 ? ✓ balanced : ✕ not balanced}.
:
No entry will be posted (see the notes above).
}
Each account gets its year-to-date amount from the old report (+ debit / − credit); the month-to-date column is kept for reference only. {s.counts ? s.counts.accountRows + " account row" + (s.counts.accountRows === 1 ? "" : "s") + " in the file" + (held ? "" : "; " + s.counts.posted + " with a balance are posted" + (s.counts.zero ? ", " + s.counts.zero + " at zero are not" : "")) + "; " + s.counts.subtotals + " subtotal rows are used only to check the file." : ""}
{s.balance ? (s.balance.ok ? "✓ The accounts in the file add up to 0.00 (debits equal credits)." : "✕ The accounts do not add up to 0.00 (year to date " + impMoney(s.balance.ytd) + ").") : ""}
{nChecks > 0 &&
{br.length ? "⚠ " + br.length + (br.length === 1 ? " subtotal row of the file does not match its accounts: " : " subtotal rows of the file do not match their accounts: ") + br.map(b => b.label + " (printed " + impMoney(b.printedYtd) + ", accounts add to " + impMoney(b.computedYtd) + ")").join("; ") + "." : "✓ All " + nChecks + " subtotal rows of the file match the accounts above them."}
} {s.alreadyPosted &&
This trial balance was already imported ({s.alreadyPosted.id}, {s.alreadyPosted.date}); it will not be posted twice.
}
{s.unknown && s.unknown.length > 0 && (
{s.unknown.length} account{s.unknown.length === 1 ? "" : "s"} not in your chart — {(s.newAccounts || []).length ? "they will be added (ticked above) with the name from the report and the type guessed from the account number (shown in brackets; change a type afterwards in the chart of accounts if it is wrong)" : "tick “Add accounts missing from the chart” above or import the chart first"}: {s.unknown.slice(0, 10).map(u => { const na = (s.newAccounts || []).find(a => a.acct === u.acct); return u.acct + (u.didYouMean ? " (did you mean " + u.didYouMean + "?)" : na && na.type ? " (" + na.type + ")" : ""); }).join(", ")}{s.unknown.length > 10 ? " … +" + (s.unknown.length - 10) : ""}
)} {s.profit && s.profit.note &&
Profit and retained earnings. {s.profit.note}{s.profit.plNet != null ? " Profit & loss accounts net to " + impMoney(s.profit.plNet) + " (debit = loss)." : ""}
} {(s.byType || []).length > 0 && (
What goes where — by account type {s.byType.map((t, i) => )}
TypeAccountsDebitsCreditsNet
{t.type}{t.accounts}{impMoney(t.debit)}{impMoney(t.credit)}{impMoney(t.net)}
)}
); } if (s.kind === "schedule") { return (
Sub-ledger detail only — nothing is posted to the General Ledger
{(s.schedules || []).map((x, i) => )}
ScheduleFillsRowsTotalOver 30 days
{x.glAcct} {x.name || ""}{x.target}{x.rows}{impMoney(x.total)}{impMoney(x.over30)}{x.over30Count != null ? " (" + x.over30Count + ")" : ""}
{s.ageNote &&
{s.ageNote}
}
{(s.reconciliation || []).length > 0 && (
Reconciliation with the General Ledger
{s.reconciliation.map((r, i) => (
{r.glAcct} {r.name || ""}: imported {impMoney(r.imported)} ({r.rows} row{r.rows === 1 ? "" : "s"}) {r.glBalance == null ? — the General Ledger has no balance for this account yet, so there is nothing to compare with. Import the trial balance to get one. : <>against the ledger balance of {impMoney(r.glBalance)}: {Math.abs(r.diff || 0) < 0.005 ? ✓ equal : difference {impMoney(r.diff)}}} {r.note ? {r.note} : null}
))}
)}
); } if (dest === "inventory" && s.units != null) { const by = s.byAccount || {}, ks = Object.keys(by).sort(); return (
Vehicles — records only, nothing is posted to the General Ledger
{s.units} vehicle{s.units === 1 ? "" : "s"}{s.collapsed ? " (" + s.collapsed + " repeated line" + (s.collapsed === 1 ? "" : "s") + " from other sort orders counted once)" : ""}{s.conflicts ? "; " + s.conflicts + " conflicting copies rejected" : ""}. GL cost total {impMoney(s.glCostTotal)} (kept as a reference figure).
{ks.length > 0 && {ks.map(k => )}
Inventory accountUnitsGL cost
{k}{by[k].units}{impMoney(by[k].glCost)}
} {s.typeFromAccount ?
New / used was taken from the inventory account for {s.typeFromAccount} vehicle{s.typeFromAccount === 1 ? "" : "s"}.
: null}
); } if (dest === "deals" && s.kind === "deals") { return
Deal history — records only
{s.note || "These deals are history: they are not posted again, are not in the funding or month-end queues, pay no commission and notify nobody."}
; } return null; }; const ImportFlow = ({ startView, onView, onRestore, onClose, preload, onBackToPacket, go }) => { const D = window.DMS, JI = window.JBImporters; const SKIP = (D && D.IMPORT_SKIP) || "__skip"; const THR = (D && D.IMPORT_THRESHOLD) || 0.75; const cards = React.useMemo(impCards, []); const [view, setView] = React.useState(startView || "wizard"); const [histFocus, setHistFocus] = React.useState(null); const [step, setStep] = React.useState(1); const [card, setCard] = React.useState(""); const [subs, setSubs] = React.useState({ gl: "glChart" }); // the destination chosen inside a group that has several const cardDef = cards.find(c => c.key === card) || null; const dest = cardDef ? (cardDef.dests.length > 1 ? (subs[card] && cardDef.dests.includes(subs[card]) ? subs[card] : cardDef.dests[0]) : cardDef.dests[0]) : ""; const def = dest && D.importDest ? D.importDest(dest) : null; const [file, setFile] = React.useState(null); // { name, sheets, note } const [sheetIx, setSheetIx] = React.useState(0); const [parsing, setParsing] = React.useState(""); const [parseErr, setParseErr] = React.useState(""); const [mapping, setMapping] = React.useState({}); const [meta, setMeta] = React.useState({ conf: {}, why: {}, suggest: {}, via: {} }); const [tpl, setTpl] = React.useState(null); // applied template const [tplHint, setTplHint] = React.useState(null); // { template, exact } const [tplName, setTplName] = React.useState(""); const [tplSource, setTplSource] = React.useState(""); const [clearBlanks, setClearBlanks] = React.useState(false); const [opts, setOpts] = React.useState({}); // the destination's plan options (as-of date, period, add accounts, schedule, append) const [pre, setPre] = React.useState(null); // the Old reports list this wizard was opened with const [filter, setFilter] = React.useState("all"); const [confirmPost, setConfirmPost] = React.useState(false); const [confirmUndo, setConfirmUndo] = React.useState(false); const [result, setResult] = React.useState(null); const [progress, setProgress] = React.useState(null); // RC25 (imp): a batch import on its way to the server { done, total, rows, rowsTotal, counts, status } const [rev, setRev] = React.useState(0); const fileRef = React.useRef(null); React.useEffect(() => (D && D.subscribe ? D.subscribe(() => setRev(r => r + 1)) : undefined), []); React.useEffect(() => { setView(startView || "wizard"); }, [startView]); // menubar "Import" / "Import history" const go2 = (v) => { setView(v); onView && onView(v); }; const sheet = file && file.sheets[sheetIx] ? file.sheets[sheetIx] : null; const rows = sheet ? sheet.rows : []; const headers = sheet ? sheet.headers : []; const fields = def ? def.fields : []; const fieldLabel = Object.fromEntries(fields.map(f => [f.key, f.label])); const unresolved = headers.filter(h => !mapping[h]); const usedBy = {}; headers.forEach(h => { const f = mapping[h]; if (f && f !== SKIP) usedBy[f] = h; }); const missing = def ? def.required.filter(g => !g.some(f => usedBy[f])).map(g => g.map(f => fieldLabel[f] || f).join(" or ")) : []; const canMapNext = headers.length > 0 && !unresolved.length && !missing.length; const neverClear = !!(def && def.blanksNeverClear); // (re)build the mapping for the chosen sheet: guess, then a saved template const initMapping = (sh, dk) => { if (!sh || !dk) return null; const g = D.importGuessMapping(dk, sh.headers, sh.rows); const via = {}; sh.headers.forEach(h => { via[h] = g.mapping[h] ? "auto" : ""; }); let m = { ...g.mapping }, applied = null; const hint = D.importTemplateFor(dk, sh.headers); if (hint && hint.exact) { const tm = D.importApplyTemplate(hint.template, sh.headers); Object.keys(tm).forEach(h => { m[h] = tm[h]; via[h] = "template"; }); applied = hint.template; } setMapping(m); setMeta({ conf: g.conf, why: g.why, suggest: g.suggest, via }); setTpl(applied); setTplHint(hint && !hint.exact ? hint : null); setTplName(applied ? applied.name : ""); setTplSource(applied ? applied.source || "" : ""); return m; }; const applyTemplate = (t) => { const tm = D.importApplyTemplate(t, headers); setMapping(m => ({ ...m, ...tm })); setMeta(x => { const via = { ...x.via }; Object.keys(tm).forEach(h => { via[h] = "template"; }); return { ...x, via }; }); setTpl(t); setTplHint(null); setTplName(t.name); setTplSource(t.source || ""); window.k2bToast && window.k2bToast(`Template "${t.name}" applied — check the mapping.`, "info"); }; const resetFile = () => { setFile(null); setSheetIx(0); setMapping({}); setMeta({ conf: {}, why: {}, suggest: {}, via: {} }); setTpl(null); setTplHint(null); setParseErr(""); setResult(null); setClearBlanks(false); setFilter("all"); setPre(null); if (fileRef.current) fileRef.current.value = ""; }; const pickDest = (c, sub) => { const cd = cards.find(x => x.key === c); const nextSubs = sub ? { ...subs, [c]: sub } : subs; const nextDest = cd ? (cd.dests.length > 1 ? (nextSubs[c] && cd.dests.includes(nextSubs[c]) ? nextSubs[c] : cd.dests[0]) : cd.dests[0]) : ""; if (nextDest !== dest && file) resetFile(); if (nextDest !== dest) setOpts(impOptDefaults(D.importDest(nextDest))); setCard(c); if (sub) setSubs(nextSubs); }; const onFile = async (f) => { if (!f || !def) return; resetFile(); // RC30 fix round (attacker L3): a file too big to read in the browser is refused before it is opened (a PDF is read page by page up to 60 MB / // 300 pages in Importers.openPdf; a spreadsheet or CSV up to 200 MB) — plain words, nothing hangs const maxMb = /\.pdf$/i.test(f.name || "") ? 60 : 200; if (f.size > maxMb * 1024 * 1024) { setParseErr(`${f.name} is ${Math.round(f.size / 1048576)} MB — the import reads ${/\.pdf$/i.test(f.name || "") ? "PDFs" : "files"} up to ${maxMb} MB. Split the file, or export the report in smaller pieces.`); return; } setParsing("Reading " + f.name + "…"); try { const res = await JI.readTable(f, (pct, st) => { if (st) setParsing(st); }, { allowDocs: dest === "customers", allowPdf: true, dest }); // RC30 (imp2): a text-layer PDF is read into columns for every destination setParsing(""); if (!res.sheets.length) { setParseErr(`No rows found in ${f.name}. The first row must hold the column names.`); return; } const best = Math.max(0, res.sheets.findIndex(s => JI.suggestDest && JI.suggestDest(s.rows) === dest)); setFile({ name: f.name, sheets: res.sheets, note: res.note }); setSheetIx(best); initMapping(res.sheets[best], dest); } catch (e) { setParsing(""); setParseErr(e && e.plain && e.message ? e.message : "Could not read this file: " + (e && e.message ? e.message : e)); } // OCR failures already carry a plain-English message }; // Old reports hand-off: one list of the packet, already in the destination's own column labels, opened at the preview const applyPreload = (pl) => { const ds = pl && pl.dataset; if (!ds) return; const cd = cards.find(c => c.dests.includes(ds.dest)); if (!cd) return; const dd = D.importDest(ds.dest); setView("wizard"); setResult(null); setFilter("all"); setClearBlanks(false); setConfirmPost(false); setCard(cd.key); setSubs(s => ({ ...s, [cd.key]: ds.dest })); const sh = { name: ds.sheetName, headers: ds.headers.slice(), rows: ds.rows.map(r => ({ ...r })), preface: [] }; setFile({ name: pl.fileName || "Old reports packet", sheets: [sh], note: "Read from the old report pages by AI reading — checked against the printed totals first." }); setSheetIx(0); const m = initMapping(sh, ds.dest); const o = { ...impOptDefaults(dd), ...(ds.importOpts || {}) }; setOpts(o); const asOfCheck = (ds.checks || []).find(c => c.id === "tb.asOf"); setPre({ id: ds.id, label: ds.sheetName, destLabel: ds.destLabel, status: ds.status, counts: ds.counts, fileName: pl.fileName, asOfIso: ds.importOpts && ds.importOpts.asOf || "", asOfSource: ds.printed && ds.printed.asOfSource || "", asOfPrinted: ds.printed && ds.printed.printedDate || "", asOfLevel: asOfCheck ? asOfCheck.level : "", pageRefs: (Array.isArray(ds.records) ? ds.records : []).map(r => { const p = r && r._page; return p ? (p.label || [p.fileName, p.pageNo ? "p." + p.pageNo : ""].filter(Boolean).join(" ")) : ""; }) }); // RC30 (imp2): each row's scanned page ("read from a scanned page — check") const req = dd.required.every(g => g.some(f => Object.keys(m || {}).some(h => m[h] === f))); const unres = sh.headers.filter(h => !m || !m[h]); setStep(req && !unres.length ? 4 : 3); }; React.useEffect(() => { if (preload && preload.nonce !== impAppliedPreload) { impAppliedPreload = preload.nonce; applyPreload(preload); } }, [preload && preload.nonce]); const setMap = (h, v) => { setMapping(m => { const n = { ...m }; if (v && v !== SKIP) Object.keys(n).forEach(k => { if (k !== h && n[k] === v) n[k] = ""; }); // one column per field — the other column needs a new choice n[h] = v; return n; }); setMeta(x => ({ ...x, via: { ...x.via, [h]: v ? "user" : "" } })); }; const skipAllUnresolved = () => { unresolved.forEach(h => setMap(h, SKIP)); }; // exactly what importPlan (and so the commit) receives const planOpts = React.useMemo(() => def ? { clearBlanks: neverClear ? false : clearBlanks, headers, fileName: file ? file.name : "", sheetName: sheet ? sheet.name : "", preface: sheet && sheet.preface ? sheet.preface : undefined, allSheets: file ? file.sheets : undefined, ...impOptsForPlan(def, opts) } : {}, [def, clearBlanks, headers, file, sheet, opts]); // RC25 (imp): allSheets — the RO_Lines sheet of a workbook const plan = React.useMemo(() => { if (step < 4 || !def || !sheet || !canMapNext) return null; try { return D.importPlan(dest, rows, mapping, planOpts); } catch (e) { return { error: "Preview failed: " + (e && e.message ? e.message : e) }; } }, [step, dest, sheet, mapping, planOpts, rev]); const blocked = !!(plan && !plan.error && plan.blocked); const saveTemplate = () => { const r = D.importSaveTemplate({ name: tplName, source: tplSource, dest, headers, mapping }); window.k2bToast && window.k2bToast(r.msg, r.ok ? "ok" : "warn"); if (r.ok) setTpl(r.template); }; const errorsCsv = (p, tag) => impDownload(`import-errors-${dest}-${impFileSlug(file && file.name)}-${tag}.csv`, D.importErrorsCsv(p)); // RC30 (imp2): EVERY destination is staged on the server (DMS.importStage) and waits for the owner's approval — this page writes // nothing and posts nothing. A PDF's rows carry their page ("read from PDF — check"); an Old-reports packet's rows their scanned page. const commit = () => { setConfirmPost(false); if (!plan || plan.error || plan.blocked) return; const meta = { file: file.name, sheet: file.sheets.length > 1 || pre ? sheet.name : "", source: pre ? "Old reports (AI reading)" : tplSource || (tpl && tpl.source) || "", template: tpl }; if (!D.importStage) { window.k2bToast && window.k2bToast("This DMS page is older than the server — reload it (Ctrl+F5).", "warn", 6000); return; } const runOpts = { origin: pre ? "ai-page" : (sheet && sheet.pdf ? "pdf-text" : "text"), pageRefs: pre && pre.pageRefs ? pre.pageRefs : undefined }; setResult(null); setProgress({ done: 0, total: 0, rows: 0, rowsTotal: plan.rows.length, counts: {}, status: "starting", staged: true }); setStep(6); D.importStage(dest, plan, meta, (p) => setProgress({ ...p, status: "running" }), runOpts).then((r) => { if (!r.ok) { setProgress({ status: "failed", msg: r.msg, lost: !!r.lost, batchNo: r.batchNo }); window.k2bToast && window.k2bToast(r.msg, "warn", 6000); if (r.blocked) { setProgress(null); setStep(4); } return; } setProgress(null); setResult(r); if (pre && window.OldReports && window.OldReports.session) window.OldReports.session.markImported(pre.id, { historyId: r.id, runId: r.runId, counts: r.counts, dest, staged: true }); window.k2bToast && window.k2bToast(`Sent for approval — ${r.msg}`, r.counts.rejected ? "warn" : "ok", 6000); }).catch((e) => { setProgress({ status: "failed", msg: (e && e.message) || String(e) }); }); }; const openReview = (runId) => { try { if (runId) localStorage.setItem("k2b-imp-review-run", runId); } catch (e) {} if (typeof go === "function") go("importreview"); else { const el = document.querySelector('.jb-sidebar [data-route="importreview"]'); if (el) el.click(); } }; const doUndo = () => { setConfirmUndo(false); const r = D.importUndo(result.id); window.k2bToast && window.k2bToast(r.msg || (r.ok ? "Import undone." : "Could not undo."), r.ok ? "ok" : "warn", 5200); }; const isJE = dest === "glJournal"; const postsGL = !!IMP_POSTS_GL[dest]; const tbEntry = plan && !plan.error && plan.summary && plan.summary.kind === "trialBalance" ? plan.summary.entry : null; const unitWord = (n) => `${n} ${isJE ? "journal entr" + (n === 1 ? "y" : "ies") : "row" + (n === 1 ? "" : "s")}`; // RC30 (imp2): the final button SENDS the rows for approval — nothing posts here (the opening balances post when the owner approves) const finalLabel = plan && !plan.error ? (isJE ? (plan.counts.added ? `Send ${plan.counts.added} journal entr${plan.counts.added === 1 ? "y" : "ies"} for approval` : "Nothing to send") : dest === "glTrial" ? (tbEntry && plan.toImport ? `Send opening balances ${tbEntry.ref} (${plan.toImport} account${plan.toImport === 1 ? "" : "s"}) for approval` : "Nothing to send") : plan.toImport ? `Send ${plan.toImport} row${plan.toImport === 1 ? "" : "s"} for approval` : "Nothing to send") : "Send for approval"; const finalCount = plan && !plan.error && !plan.blocked ? (isJE ? plan.counts.added : plan.toImport) : 0; // ── shared bits ── const box = { background: "#FFF", border: "1px solid #C9D6E3", borderRadius: 4 }; const cell = { padding: "4px 7px", borderTop: "1px solid #EEE", verticalAlign: "top" }; const hcell = { padding: "5px 7px", textAlign: "left", position: "sticky", top: 0, background: "#F0F3F7", zIndex: 1, whiteSpace: "nowrap", fontWeight: 700, borderBottom: "1px solid #D6DEE7" }; const btn = (primary, extra) => ({ fontWeight: 700, padding: "5px 14px", background: primary ? "#FFE9B0" : undefined, ...(extra || {}) }); if (view === "history") return go2("wizard")} rev={rev} go={go} />; // RC25 (imp): the owner's multi-sheet old-system workbook, every sheet in the README's load order const wbInfo = file && window.ImpWorkbook ? window.ImpWorkbook.detect(file.sheets) : null; if (view === "workbook" && file && window.ImpWorkbookFlow) return setView("wizard")} onClose={onClose} />; const stepper = (
{IMP_STEPS.map(([n, label]) => { const cur = step === n || (n === 5 && step === 6); const done = step > n && !(n === 5 && step === 6); return ( {done || step === 6 ? "✓" : n} {label} {n < 5 && ›} ); })} {def && → {def.label}} {file && {file.name}{file.sheets.length > 1 ? ` · ${sheet.name}` : ""}}
); // ── step bodies ── let body = null; if (step === 1) body = (
What are you importing?
Pick where the data goes first. Next you choose the file, check how its columns map to DMS fields, and see exactly what will be added, updated, skipped or rejected. Nothing is saved until the owner approves the import (Accounting › Import review & approval): the rows wait on the server, with every problem explained and the totals tied to the general ledger. Have scanned old reports (PDF or photos)? Use Old reports in the menu bar: it reads them and brings the lists here.
Need the layout? Download the import template: K2B-Import-Template.xlsx (every sheet, with a column guide), or one CSV per sheet: {["Chart_of_Accounts", "GL_Opening_Balances", "Journal_Entries", "Vendors", "Check_Register", "Customers", "Service_Customers", "Vehicles", "Inventory", "Floorplan_Statement", "Parts", "Aged_Schedules", "AR_Aging", "Warranty_Claims", "Body_Shop_WIP", "Sales_Deals", "Repair_Orders", "RO_Lines", "Tie_Out"].map(n => {n})} {window.ImpTemplateGuideButton ? : null}{/* RC36 (imp36): a template for every import — its CSV, sheet, made-up sample row and what each column means / which GL account it ties to */} · the customer_number column is the 7-digit customer # (the phone without the area code)
{cards.map(c => { const dk = c.dests.length > 1 ? (subs[c.key] && c.dests.includes(subs[c.key]) ? subs[c.key] : c.dests[0]) : c.dests[0]; const dd = D.importDest(dk); const allowed = !D.can || !dd || D.can(dd.perm); const on = card === c.key; return (
allowed && pickDest(c.key)} style={{ ...box, padding: "12px 14px", cursor: allowed ? "pointer" : "not-allowed", opacity: allowed ? 1 : 0.55, minHeight: 118, border: on ? "2px solid #1B66A8" : "1px solid #C9D6E3", background: on ? "#EEF5FC" : "#FFF", boxShadow: on ? "0 0 0 3px rgba(27,102,168,.12)" : "none" }}>
{c.icon} {c.title} {on && ✓}
{c.blurb}
{c.dests.length > 1 && (
{c.dests.map(k => ( { e.stopPropagation(); const sd = D.importDest(k); if (!D.can || !sd || D.can(sd.perm)) pickDest(c.key, k); }} style={{ fontSize: 11, fontWeight: 700, padding: "3px 9px", borderRadius: 999, cursor: "pointer", border: "1px solid " + (on && dk === k ? "#1B66A8" : "#BCCBDA"), background: on && dk === k ? "#1B66A8" : "#FFF", color: on && dk === k ? "#FFF" : "var(--k2b-navy)" }}> {on && dk === k ? "● " : "○ "}{IMP_SUB_LABEL[k] || (D.importDest(k) || {}).short || k} ))}
)} {!allowed &&
Your role can't import here.
}
); })}
{def &&
{def.label}: {def.match}{dest === "glJournal" ? " Entries must balance, use accounts in the chart and fall in an open period." : ""}{IMP_POSTS_GL[dest] ? "" : dest === "glChart" ? "" : " Nothing is posted to the General Ledger."}
}
Restoring a full DMS backup (.json / .jbz) instead? { e.preventDefault(); onRestore && onRestore(); }}>Restore backup →
); if (step === 2) body = (
Choose the file for {def.label}
CSV, Excel (.xls / .xlsx) or a PDF printout with the column names in the first row{dest === "customers" ? ". Customer sheets as Word / photos are read with OCR too" : ""}. The file is read here in your browser — nothing is saved yet. A PDF is read from its text (every row is marked “read from PDF — check” until approved); pages with no text layer are scanned pages — read those in Old reports. {["glTrial", "schedule", "deals", "glChart", "inventory"].includes(dest) && <> A scanned or photographed old report (photo / scanned PDF)? Open Old reports in the menu bar instead — it reads the pages and brings the list here, checked against the printed totals.} {" "}Template: K2B-Import-Template.xlsx.
{pre &&
✓ Loaded from Old reports: {pre.label} ({file ? file.sheets[0].rows.length : 0} rows).
} {wbInfo && wbInfo.ok && (
This is the whole old-system workbook — {wbInfo.recognised} sheets recognised ({wbInfo.names.join(", ")}){wbInfo.unknown && wbInfo.unknown.length ? · not recognised (not imported): {wbInfo.unknown.map(u => u.name).join(", ")} : null}{wbInfo.missing && wbInfo.missing.indexOf("GL_Opening_Balances") !== -1 ? · no GL_Opening_Balances sheet : null}. It can be imported as ONE job, every sheet to its own destination in the README's load order, with the counts per sheet shown first.
)} onFile(e.target.files[0])} style={{ display: "none" }} />
{file && {file.name}} {file && }
{parsing &&
⏳ {parsing}
} {parseErr &&
⚠ {parseErr}
}
{file && sheet && (
✓ Read {rows.length} row{rows.length === 1 ? "" : "s"} · {headers.length} column{headers.length === 1 ? "" : "s"} {file.sheets.length > 1 && ( )} {file.note && {file.note}}
Columns: {headers.join(" · ")}
{sheet.formulas > 0 &&
⚠ {sheet.formulas} cell{sheet.formulas === 1 ? " of this sheet is a formula" : "s of this sheet are formulas"} with no saved result (a file written by a program, never opened in Excel). They are NOT read as $0.00: those rows are refused on the preview with the reason. Open the file in Excel and save it (or paste the values), then choose it again.
} {(() => { const sg = JI.suggestDest ? JI.suggestDest(rows) : null; return sg && sg !== dest ? (
⚠ This file looks more like {(D.importDest(sg) || {}).label}. It will still go to {def.label} unless you go back and change the destination.
) : null; })()} {tpl &&
✓ Saved template {tpl.name}{tpl.source ? ` (${tpl.source})` : ""} matches these columns — its mapping is applied. Check it on the next step.
} {tplHint &&
Template {tplHint.template.name} is a close match ({Math.round(tplHint.score * 100)}% of columns).
}
)}
); if (step === 3) { const nNeed = unresolved.length; body = (
Map the file's columns to {def.short} fields
{nNeed ? `${nNeed} column${nNeed === 1 ? "" : "s"} need${nNeed === 1 ? "s" : ""} your choice` : "✓ every column has a destination"} {nNeed > 0 && }
{headers.map(h => { const v = mapping[h] || ""; const via = meta.via[h] || ""; const need = !v; const samples = []; rows.some(r => { const x = String((r || {})[h] ?? "").trim(); if (x && !samples.includes(x)) samples.push(x); return samples.length >= 3; }); const c = meta.conf[h] || 0; const sug = meta.suggest[h]; return ( ); })}
File columnSample valuesConfidenceImports into (DMS field)
{h} {samples.join(" · ") || — empty —} {need ? impBadge("#F9D9A8", "#8A4B00", "Needs your choice") : via === "template" ? impBadge("#DDE8F7", "#1B4F8A", "Template") : via === "user" ? impBadge("#E6E0F5", "#4B3A8A", "Your choice") : impBadge(c >= 0.95 ? "#D9F0DE" : "#EAF3DD", c >= 0.95 ? "#176A38" : "#4C6B1E", `${Math.round(c * 100)}% match`)} {need &&
{meta.why[h] || "no matching field"}{c ? ` (${Math.round(c * 100)}%)` : ""}
}
{need && sug && }
Required
{def.required.map((g, i) => { const ok = g.some(f => usedBy[f]); return
{ok ? "✓" : "✗"} {g.map(f => fieldLabel[f] || f).join(" or ")}
; })}
{def.match}
Columns set to Skip are not imported; their values are kept in the import history record.
Save this mapping as a template
Next time a file from the same source has these columns, the mapping is applied automatically.
setTplName(e.target.value)} style={{ width: "100%", boxSizing: "border-box", font: "inherit", padding: "3px 6px", marginBottom: 4 }} /> setTplSource(e.target.value)} style={{ width: "100%", boxSizing: "border-box", font: "inherit", padding: "3px 6px", marginBottom: 6 }} /> {nNeed > 0 &&
Resolve every column first.
} {tpl &&
Using template: {tpl.name}
}
); } if (step === 4) { if (!plan) body =
Finish the column mapping first.
; else if (plan.error) body =
{plan.error}
; else { const items = plan.items.filter(it => filter === "all" || it.action === filter); const shown = items.slice(0, 500); const pvCols = (IMP_PV_COLS[dest] || []).map(k => fields.find(f => f.key === k)).filter(f => f && usedBy[f.key]); const isMoney = (f) => f.type === "money"; body = (
Preview — nothing has been saved
{plan.rows.length} row{plan.rows.length === 1 ? "" : "s"} in file{isJE ? ` → ${plan.items.length} entr${plan.items.length === 1 ? "y" : "ies"}` : ""}
{pre &&
From Old reports: {pre.label} — {pre.counts ? pre.counts.ok + " check" + (pre.counts.ok === 1 ? "" : "s") + " passed" + (pre.counts.warn ? ", " + pre.counts.warn + " amber" : "") + (pre.counts.error ? ", " + pre.counts.error + " red" : "") : ""}. {onBackToPacket && { e.preventDefault(); onBackToPacket(); }}>← Back to the packet}
}
{[["all", "All", plan.items.length, "#E8EEF5", "var(--k2b-navy)"], ["add", def && def.async ? "To send" : "Add", plan.counts.added], ["update", "Update", plan.counts.updated], ["skip", "Skip", plan.counts.skipped], ["reject", "Reject", plan.counts.rejected]].map(([k, l, n, bg, fg]) => { const a = IMP_ACT[k] || { bg, fg }; return ( ); })} {!isJE && !neverClear && ( )} {neverClear && A blank cell never erases anything for this kind of import.} {def && def.async && These rows are checked by the server when they arrive: it adds the new ones, updates only where the file differs and leaves the rest unchanged — the last step shows its counts. (This station cannot see the history already on the server.)}
{plan.unmapped.length > 0 && (
Not imported — kept in the import history: {plan.unmapped.map(u => `${u.header} (${u.values.length} value${u.values.length === 1 ? "" : "s"}${u.samples.length ? ", e.g. " + u.samples.slice(0, 2).join(", ") : ""})`).join(" · ")}
)} {dest === "checks" &&
Check-register import adds register records only — no GL entries are posted. Checks that already have a GL entry (CK-#) are linked, never posted twice.
} {isJE &&
Each entry must balance, use accounts in the chart and fall in an open period. Nothing posts until you confirm on the last step.
}
{pvCols.map(f => )} {shown.map((it, i) => { const a = IMP_ACT[it.action]; return ( {pvCols.map(f => { const v = it.raw ? it.raw[usedBy[f.key]] : ""; return ; })} ); })} {!shown.length && } {items.length > shown.length && }
Row{isJE ? "s" : ""}Result{isJE ? "Entry ref" : dest === "glTrial" ? "Account" : dest === "deals" ? "Deal #" : "Record"}{isJE ? "Date · lines · amount" : "Name / description"}{f.label}What will happen
{it.rows.length > 3 ? `${it.rows[0]}–${it.rows[it.rows.length - 1]}` : it.rows.join(", ")} {impBadge(a.bg, a.fg, a.label)} {it.key} {it.label || —}{v === "" || v == null ? — : isMoney(f) && !isNaN(parseFloat(v)) ? impMoney(parseFloat(v)) : String(v)} {it.action === "add" &&
New {def.unit}{it.note ? ` — ${it.note}` : ""}
} {it.action === "update" &&
Matched by {it.matchBy}: {Object.keys(it.changes).map(f => {fieldLabel[f] || f} {impFmtVal(it.changes[f].from)} → {it.changes[f].to === "" || it.changes[f].to === 0 && clearBlanks && it.changes[f].from ? {impFmtVal(it.changes[f].to)} (cleared) : impFmtVal(it.changes[f].to)})}
} {it.action === "skip" &&
{it.note || "no change"}
} {(it.reasons || []).map((r, k) =>
✗ {r.reason}
)} {(it.warnings || []).map((r, k) =>
⚠ {r.reason}
)} {isJE && it.entry && it.entry.lines.length > 0 && ( {it.entry.lines.map((l, k) => ( ))}
{l.acct}{(D.acctName && D.acctName(l.acct)) || ""} {l.dr ? l.dr.toFixed(2) : ""}{l.cr ? l.cr.toFixed(2) : ""}{l.desc}{l.control ? " · control " + l.control : ""}
)}
No rows in this group.
… {items.length - shown.length} more — all are counted, imported and listed in the error list.
); } } if (step === 5 && plan && !plan.error) { const c = plan.counts; const mappedCols = headers.filter(h => mapping[h] && mapping[h] !== SKIP); const keysOf = (act) => plan.items.filter(it => it.action === act).map(it => it.key); const list = (arr) => arr.length ? arr.slice(0, 12).join(", ") + (arr.length > 12 ? ` … +${arr.length - 12}` : "") : "—"; const jeTotal = isJE ? plan.items.filter(it => it.action === "add").reduce((s, it) => s + ((it.totals && it.totals.dr) || 0), 0) : 0; const opLines = ((def && def.optionDefs) || []).map(od => { const v = opts[od.key]; if (od.type === "bool") return [od.label, v ? "Yes" : "No"]; if (od.type === "date") return [od.label, v ? impIso(v).slice(5, 7) + "/" + impIso(v).slice(8, 10) + "/" + impIso(v).slice(0, 4) : "—"]; if (od.type === "select") { const o = (od.options || []).find(x => String(x[0]) === String(v)); return [od.label, o ? o[1] : String(v || "—")]; } return [od.label, v ? String(v) : "—"]; }); body = (
Ready to import — review the summary
Destination{def.label} File{file.name}{file.sheets.length > 1 || pre ? ` · ${sheet.name}` : ""} — {plan.rows.length} row{plan.rows.length === 1 ? "" : "s"} Mapping{mappedCols.length} column{mappedCols.length === 1 ? "" : "s"} imported{tpl ? ` · template “${tpl.name}”` : ""} Skipped columns{plan.unmapped.length ? plan.unmapped.map(u => u.header).join(", ") + " — values kept in the import history" : "none"} {!isJE && !neverClear && <>Blank cells{clearBlanks ? "CLEAR existing values on matched records" : "keep existing values"}} {neverClear && <>Blank cellsnever erase anything} {opLines.map(([k, v]) => {k.length > 38 ? k.slice(0, 36) + "…" : k}{v})}
{impBadge(IMP_ACT.add.bg, IMP_ACT.add.fg, "ADD")} {c.added} {isJE ? `journal entr${c.added === 1 ? "y" : "ies"} will be posted` : `new ${def.unit}${c.added === 1 ? "" : "s"}`}: {list(keysOf("add"))}
{!isJE &&
{impBadge(IMP_ACT.update.bg, IMP_ACT.update.fg, "UPDATE")} {c.updated} existing {def.unit}{c.updated === 1 ? "" : "s"} (only the changed fields): {list(keysOf("update"))}
}
{impBadge(IMP_ACT.skip.bg, IMP_ACT.skip.fg, "SKIP")} {c.skipped} already on file, no change: {list(keysOf("skip"))}
{impBadge(IMP_ACT.reject.bg, IMP_ACT.reject.fg, "REJECT")} {c.rejected} not imported (see error list): {list(keysOf("reject"))}
{isJE &&
This posts to the General Ledger. {c.added} balanced entr{c.added === 1 ? "y" : "ies"} totaling {jeTotal.toLocaleString("en-US", { style: "currency", currency: "USD" })} will post with source “Import”. Posted entries can only be corrected by reversing them in Accounting.
} {dest === "glTrial" && tbEntry &&
This posts to the General Ledger. One opening-balances entry ({tbEntry.ref}, {tbEntry.date}) with {plan.toImport} account line{plan.toImport === 1 ? "" : "s"}. You can undo it afterwards (it is reversed, not deleted).
} {dest === "checks" &&
Register records only — no GL entries will be posted for these checks.
} {(dest === "schedule" || dest === "inventory" || dest === "deals" || dest === "partsMaster" || dest === "vehicleMaster" || dest === "svcCustomers" || dest === "arAging" || dest === "warrantyClaims" || dest === "bodyWip") &&
Records only — nothing is posted to the General Ledger.
} {dest === "roHistory" && plan.summary &&
History only — no journal entry (the closed repair orders are already in the old books). The rows go to the server in {plan.summary.batches || 1} batch{(plan.summary.batches || 1) === 1 ? "" : "es"}; you will see the progress, and a dropped connection continues where it stopped.
}
Nothing is saved or posted by this button. The rows are sent to the server and wait for the owner's approval on Accounting › Import review & approval, where every problem row is explained and the lists are tied to the general ledger. An import-history record is written when the run is approved.
); } if (step === 6 && !result && progress) { // RC25 (imp): the batches on their way const pct = progress.total ? Math.round(progress.done / progress.total * 100) : 0; const c = progress.counts || {}; body = (
{progress.status === "failed" ? "⚠ The import stopped" : progress.status === "starting" ? "Starting the import…" : `Sending to the server — batch ${progress.done} of ${progress.total}`}
{progress.status !== "failed" && <>
{(progress.rows || 0).toLocaleString("en-US")} of {(progress.rowsTotal || 0).toLocaleString("en-US")} rows sent ({pct}%){c.added != null ? ` · so far on the server: ${c.added || 0} added, ${c.updated || 0} updated, ${c.unchanged || 0} unchanged, ${c.rejected || 0} rejected` : ""}{progress.resumed ? " · continued from an earlier run" : ""}
Each batch is one step on the server: a batch that is sent twice (a dropped connection, a retry) is never counted twice. You can keep working in another tab; if this page is reloaded, choose the same file again and the import continues from the first batch the server has not seen.
} {progress.status === "failed" &&
{progress.msg}{progress.batchNo ? ` (stopped at batch ${progress.batchNo})` : ""}
}
); } if (step === 6 && result && result.staged) { // RC30 (imp2): the rows are on the server, waiting for the owner's approval const h = result.history, c = result.counts || {}; body = (
⏳ Sent for approval — {def.label}
{c.staged || 0} rows ready0 written yet{c.skipped || 0} skipped{c.rejected || 0} with problems Import run {result.runId} · {impStamp(h.at)} · {h.by || "—"}
{result.msg &&
{result.msg}
} {result.server &&
Server run {result.server.runId} · {result.server.batches} batch{result.server.batches === 1 ? "" : "es"} · status Waiting for approval — nothing is in the records or the general ledger yet.
}
Next: the owner reviews and approves. The review screen shows every row with a problem and what it most likely means, ties the lists to the general ledger as of the as-of date, and offers the reconciling entries. Approval writes the records and posts the ticked entries in one step; it can be undone while nothing has used the data.
{!pre && }
); } else if (step === 6 && result) { const h = result.history; const nx = IMP_NEXT[dest]; const fresh = (D.importHistory() || []).find(x => x.id === h.id) || h; const undone = fresh.undone; const canUndo = !!(fresh.undo && fresh.undo.kind && !undone); const firstDeal = dest === "deals" && h.affected && h.affected[0] ? h.affected[0].id : null; body = (
{undone ? "↩ Import undone — " : "✓ Import complete — "}{def.label}
{h.counts.added} added{h.counts.updated} updated{h.counts.skipped} skipped{h.counts.rejected} rejected History record {h.id} · {impStamp(h.at)} · {h.by || "—"}
{result.msg &&
{result.msg}
} {result.server &&
Server run {result.server.runId} · {result.server.batches} batch{result.server.batches === 1 ? "" : "es"}{dest === "roHistory" && result.summary && result.summary.kind === "roHistory" ? ` · closed ${result.server.closed} · open ${result.server.open} (${result.server.boardOpened} put on the board) · held ${result.server.held} · void ${result.server.voided}${result.server.boardUntouched ? ` · ${result.server.boardUntouched} started in K2B — untouched` : ""}` : ""}{dest === "roHistory" && result.summary && result.summary.kind === "roLines" ? ` · lines attached to ${result.server.linesAttached} repair orders${result.server.linesNoRo ? `, ${result.server.linesNoRo} not imported yet` : ""}` : ""}
} {IMP_RECONCILE_AFTER[dest] && !undone && (
Now tie it to the General Ledger. The reconciliation screen shows, per inventory account, what this list says is on hand next to what the ledger carries, the difference, and the two ways to post it.
)} {undone &&
{fresh.undone.removed != null ? "Removed " + fresh.undone.removed + (fresh.undone.kept ? ", kept " + fresh.undone.kept + " that were edited since" : "") + ". " : ""}Undone {impStamp(fresh.undone.at)} by {fresh.undone.by || "—"}.
} {result.late && result.late.length > 0 &&
⚠ {result.late.length} entr{result.late.length === 1 ? "y was" : "ies were"} refused when posting: {result.late.map(it => `${it.key} (${it.reasons[it.reasons.length - 1].reason})`).join("; ")}
} {nx && !undone && (
What to check next
{nx.why}
{dest === "inventory" && } {pre && onBackToPacket && }
)}
{canUndo && } {!pre && }
); } // ── footer (always visible) ── const nextOk = step === 1 ? !!def : step === 2 ? !!(sheet && rows.length) : step === 3 ? canMapNext : step === 4 ? !!(plan && !plan.error && !blocked) : false; const whyNot = step === 3 && !canMapNext ? (unresolved.length ? `Choose a field or Skip for: ${unresolved.join(", ")}` : missing.length ? `Map the required field${missing.length === 1 ? "" : "s"}: ${missing.join("; ")}` : "") : step === 4 && blocked ? "Fix the problems shown in red first" : step === 5 && blocked ? "Fix the problems shown in red (go back to the preview)" : ""; return (
{stepper}
{body}
{step > 1 && step < 6 && } {step === 6 && !result && progress && progress.status !== "failed" && ⏳ sending…} {pre && onBackToPacket && step < 6 && } {whyNot} {step < 5 && } {step === 5 && ( )}
{confirmPost && plan && ( setConfirmPost(false)} buttons={<> setConfirmPost(false)}>Cancel }> {dest === "glTrial" && tbEntry ? (
This is sent for approval — it posts to the General Ledger only when the owner approves the run. One balanced entry, source {tbEntry.src}:
{tbEntry.ref} · dated {tbEntry.date}
{plan.toImport} account line{plan.toImport === 1 ? "" : "s"} · debits {impMoney(tbEntry.debits)} = credits {impMoney(tbEntry.credits)}
{(plan.summary.newAccounts || []).length > 0 &&
{plan.summary.newAccounts.length} account{plan.summary.newAccounts.length === 1 ? "" : "s"} will be added to the chart.
}
The entry can be undone afterwards from the result screen or the import history (it is reversed in the General Ledger, not deleted).
) : (
These balanced entries will post to the General Ledger with source Import:
{plan.items.filter(it => it.action === "add").map(it => ( ))}
{it.key}{it.entry.date}{it.entry.desc}{(it.totals.dr || 0).toFixed(2)}
{plan.counts.rejected > 0 &&
{plan.counts.rejected} rejected entr{plan.counts.rejected === 1 ? "y is" : "ies are"} NOT posted.
}
Posted entries can only be corrected by reversing them in Accounting.
)}
)} {confirmUndo && result && ( setConfirmUndo(false)} buttons={<> setConfirmUndo(false)}>Keep it }>
{dest === "glTrial" ? "The opening-balances entry is reversed in the General Ledger (a reversing entry is posted; nothing is deleted). Accounts that were added to the chart stay." : "The rows this import added are removed. Rows you edited since are kept and listed."}
)}
); }; /* Import history: who / when / file / mapping / counts / affected records. */ const ImportHistoryView = ({ focus, onFocus, onBack, rev, go }) => { const D = window.DMS; const SKIP = (D && D.IMPORT_SKIP) || "__skip"; const hist = (D && D.importHistory ? D.importHistory() : []) || []; const [open, setOpen] = React.useState(null); // affected record id expanded const [undoFor, setUndoFor] = React.useState(null); // history record waiting for "Undo this import?" confirmation const sel = hist.find(h => h.id === focus) || null; const cell = { padding: "4px 7px", borderTop: "1px solid #EEE", verticalAlign: "top" }; const hcell = { padding: "5px 7px", textAlign: "left", position: "sticky", top: 0, background: "#F0F3F7", zIndex: 1, whiteSpace: "nowrap", fontWeight: 700, borderBottom: "1px solid #D6DEE7" }; const btn = (primary) => ({ fontWeight: 700, padding: "4px 12px", background: primary ? "#FFE9B0" : undefined }); const box = { background: "#FFF", border: "1px solid #C9D6E3", borderRadius: 4 }; const def = sel && D.importDest ? D.importDest(sel.dest) : null; const fieldLabel = def ? Object.fromEntries(def.fields.map(f => [f.key, f.label])) : {}; const recView = (rec) => { if (!rec) return not found now (deleted or renamed since the import); if (sel.dest === "glJournal") return (
{rec.date} · {rec.desc} · src {rec.src}{rec.reversedBy ? ` · reversed by ${rec.reversedBy}` : ""}
{(rec.lines || []).map((l, k) => )}
{l.acct}{l.dr ? l.dr.toFixed(2) : ""}{l.cr ? l.cr.toFixed(2) : ""}{l.desc}
); const skipKeys = { edits: 1, saved: 1, importId: 1, importedAt: 1, _has: 1 }; return (
{Object.keys(rec).filter(k => !skipKeys[k] && rec[k] !== "" && rec[k] != null && typeof rec[k] !== "object").slice(0, 24).map(k => ( {fieldLabel[k] || k}: {/^\d{4}-\d{2}-\d{2}T/.test(String(rec[k])) ? new Date(rec[k]).toLocaleDateString("en-US") : String(rec[k])}))}
); }; return (
🕘 Import history {hist.length} import{hist.length === 1 ? "" : "s"}
{["When", "By", "Destination", "File", "Template", "Added", "Updated", "Skipped", "Rejected", "State", ""].map(x => )} {hist.map(h => ( { onFocus(h.id); setOpen(null); }}> {["added", "updated", "skipped", "rejected"].map(k => )} ))} {!hist.length && }
{x}
{impStamp(h.at)}{h.by || "—"}{h.destLabel} {h.file}{h.sheet ? ` · ${h.sheet}` : ""}{h.template ? h.template.name : "—"}{h.counts[k]}{h.undone ? impBadge("#FFF0CC", "#8A6400", "UNDONE") : h.undo && h.undo.kind ? impBadge("#E8EEF5", "#1B4F8A", "can undo") : —} Details ›
No imports yet.
{sel && (
{sel.destLabel} — {sel.file} {impStamp(sel.at)} · by {sel.by || "—"} · {sel.rowCount} row{sel.rowCount === 1 ? "" : "s"} · {sel.id}{sel.clearBlanks ? " · blank cells cleared values" : ""} {sel.undo && sel.undo.kind && !sel.undone && } {(sel.unmapped || []).length > 0 && }
{sel.undone &&
↩ Undone {impStamp(sel.undone.at)} by {sel.undone.by || "—"}{sel.undone.removed != null ? " — removed " + sel.undone.removed + (sel.undone.kept ? ", kept " + sel.undone.kept + " that were edited since" : "") : ""}.
} {sel.summary && (sel.summary.note || sel.summary.kind) && (
{sel.summary.note ?
{sel.summary.note}
: null} {sel.summary.kind === "trialBalance" && sel.summary.entry ?
Entry {sel.summary.entry.ref} dated {sel.summary.entry.date}: debits {impMoney(sel.summary.entry.debits)} = credits {impMoney(sel.summary.entry.credits)}.
: null} {sel.summary.kind === "schedule" && (sel.summary.schedules || []).length ?
{sel.summary.schedules.map(x => x.glAcct + ": " + x.rows + " rows, " + impMoney(x.total)).join(" · ")}
: null} {sel.undo && sel.undo.how && !sel.undone ?
How to undo: {sel.undo.kind === "journal" ? "the entry is reversed in the General Ledger" : "the unedited rows this import added are removed"} (the Undo button above).
: null}
)} {sel.options && Object.keys(sel.options).some(k => sel.options[k] !== "" && sel.options[k] != null && sel.options[k] !== false) && (
Options used: {Object.keys(sel.options).filter(k => sel.options[k] !== "" && sel.options[k] != null && sel.options[k] !== false).map(k => k + " = " + (sel.options[k] === true ? "yes" : String(sel.options[k]))).join(" · ")}
)}
Mapping{sel.template ? ` (template “${sel.template.name}”)` : ""}
{Object.keys(sel.mapping || {}).map(hd => )}
{hd}→ {sel.mapping[hd] === SKIP || !sel.mapping[hd] ? "Skipped (kept below)" : fieldLabel[sel.mapping[hd]] || sel.mapping[hd]}
Skipped columns — values kept
{(sel.unmapped || []).length ? (sel.unmapped || []).map(u => (
{u.header}: {u.values.length} value{u.values.length === 1 ? "" : "s"}{u.truncated ? "+" : ""} — {u.values.slice(0, 6).map(x => `row ${x.row}: ${x.value}`).join(" · ")}{u.values.length > 6 ? " …" : ""}
)) :
none — every column was imported
}
Affected records ({(sel.affected || []).length})
{(sel.affected || []).map(a => ( {open === a.id && } ))} {!(sel.affected || []).length && }
RecordActionNameFile rowChanged
{a.id} {a.action === "added" ? impBadge(IMP_ACT.add.bg, IMP_ACT.add.fg, "ADDED") : impBadge(IMP_ACT.update.bg, IMP_ACT.update.fg, "UPDATED")} {a.label}{(a.rows || []).join(", ")} {a.changes ? Object.keys(a.changes).map(f => `${fieldLabel[f] || f}: ${impFmtVal(a.changes[f].from)} → ${impFmtVal(a.changes[f].to)}`).join(" · ") : ""}
{recView(D.importRecord(sel.dest, a.id))}
No records were added or changed.
{(sel.errors || []).length > 0 && (
Rejected rows & warnings ({sel.errors.length})
{sel.errors.slice(0, 300).map((e, k) => )}
row {e.row}{e.key}{e.level}{e.field}{e.reason}
)}
)}
{undoFor && ( setUndoFor(null)} buttons={<> setUndoFor(null)}>Keep it }>
{undoFor.dest === "glTrial" || undoFor.undo.kind === "journal" ? "The entry is reversed in the General Ledger (a reversing entry is posted; nothing is deleted)." : "The rows this import added are removed. Rows you edited since are kept and listed."} {undoFor.destLabel} — {undoFor.file}
)}
); }; /* ─── Import wizard (REAL — parses & loads a file into the ledger) ─────── */ // Tolerant CSV → array-of-objects parser (handles quoted fields). const parseCSV = (text) => { const rows = []; let row = [], cur = "", q = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (q) { if (c === '"' && text[i + 1] === '"') { cur += '"'; i++; } else if (c === '"') q = false; else cur += c; } else { if (c === '"') q = true; else if (c === ",") { row.push(cur); cur = ""; } else if (c === "\n" || c === "\r") { if (cur !== "" || row.length) { row.push(cur); rows.push(row); row = []; cur = ""; } if (c === "\r" && text[i + 1] === "\n") i++; } else cur += c; } } if (cur !== "" || row.length) { row.push(cur); rows.push(row); } if (!rows.length) return []; const head = rows[0].map(h => h.trim()); return rows.slice(1).filter(r => r.some(c => c.trim() !== "")).map(r => { const o = {}; head.forEach((h, i) => o[h] = (r[i] || "").trim()); return o; }); }; // Map a CSV record set into the DMS dataset it most resembles. const csvToDataset = (records) => { if (!records.length) return { key: null, rows: [] }; const keys = Object.keys(records[0]).map(k => k.toLowerCase()); const has = (...names) => names.some(n => keys.some(k => k.includes(n))); if (has("vin", "stock")) return { key: "inventory", rows: records }; if (has("acct", "account") && has("name", "customer")) return { key: "arCustomers", rows: records }; if (has("buyer", "deal")) return { key: "deals", rows: records }; return { key: "arCustomers", rows: records }; }; const ImportWizard = ({ step, setStep, source, setSource, path, setPath, picked, setPicked, onClose }) => { const [file, setFile] = React.useState(null); const [parsed, setParsed] = React.useState(null); // { data, label } ready for import const [parseErr, setParseErr] = React.useState(""); const [needPass, setNeedPass] = React.useState(false); const [passText, setPassText] = React.useState(""); const [encBlob, setEncBlob] = React.useState(null); const [mode, setMode] = React.useState("merge"); // merge | replace const [progress, setProgress] = React.useState(0); const [done, setDone] = React.useState(false); const [result, setResult] = React.useState(null); // Document parsing (PDF/Word/Excel/OCR) const [parsing, setParsing] = React.useState(false); const [parsePct, setParsePct] = React.useState(0); const [parseStat, setParseStat] = React.useState(""); const [custPreview, setCustPreview] = React.useState(null); // extracted customer records const fileRef = React.useRef(null); const simRows = IMPORT_TABLES.filter(t => picked[t.key]).reduce((s, t) => s + t.rows, 0); const ingestText = (text, name) => { setParseErr(""); setNeedPass(false); setEncBlob(null); const trimmed = (text || "").trim(); if (trimmed.startsWith("JBZ1:")) { // our encrypted backup → need passphrase setEncBlob(trimmed); setNeedPass(true); setParsed(null); return; } try { // JSON backup (JBREWS or generic) const obj = JSON.parse(trimmed); const data = obj && obj.data ? obj.data : obj; setParsed({ data, label: obj && obj.format ? `${obj.format} backup` : "JSON export", source: name, isBackup: !!(obj && obj.format) }); return; } catch (e) {} // Fall back to CSV / fixed-width legacy "Z file". const records = parseCSV(trimmed); if (records.length) { // Classify + map through the shared importer so vehicle inventory gets // the column mapping / preview and the stock-#/VIN merge (never a raw append). const JI = window.JBImporters; const ds = JI ? JI.datasetFromRows(records, name) : null; if (ds && ds.count) { setParsed({ datasets: [ds], label: `${ds.count} ${ds.label} (CSV)`, source: name }); return; } const guess = csvToDataset(records); if (guess.key === "inventory") { setParsed({ datasets: [{ collection: "inventory", label: "Vehicle Inventory", confidence: "low", sheet: name, count: records.length, rows: records, raw: records }], label: `${records.length} Vehicle Inventory (CSV)`, source: name }); return; } setParsed({ data: { [guess.key]: guess.rows }, label: `${records.length} ${guess.key} rows (CSV)`, source: name }); } else { setParseErr("Could not read this file. Supported: DMS .json / .jbz backups, or CSV exports."); setParsed(null); } }; const onFile = async (f) => { if (!f) return; setFile(f); setParseErr(""); setParsed(null); setCustPreview(null); setNeedPass(false); setEncBlob(null); const e = (f.name.split(".").pop() || "").toLowerCase(); // Restore takes DMS backups only. Spreadsheets / documents go through // Import (destination → mapping → preview → explicit import). if (e !== "json" && e !== "jbz") { setParseErr("Restore takes a DMS backup (.json / .jbz). Spreadsheets and documents go through Import — pick a destination first (Import menu)."); return; } const docType = ["xlsx", "xls", "docx", "pdf", "png", "jpg", "jpeg", "gif", "bmp", "tif", "tiff", "webp"].includes(e); // Plain text/JSON/CSV → fast path through the existing text ingester. if (!docType && !window.JBImporters) { const reader = new FileReader(); reader.onload = () => ingestText(String(reader.result), f.name); reader.readAsText(f); return; } try { setParsing(true); setParsePct(0); setParseStat("Opening " + f.name + "…"); const res = await window.JBImporters.parseFile(f, (pct, st) => { setParsePct(pct); if (st) setParseStat(st); }); setParsing(false); if (res.kind === "jbz") { setEncBlob(res.blob); setNeedPass(true); return; } if (res.kind === "json") { setParsed({ data: res.data, label: res.label, source: f.name, isBackup: /backup$/.test(res.label || "") }); return; } if (res.kind === "csvtext") { ingestText(res.text, f.name); return; } if (res.kind === "multi") { if (!res.datasets.length) { setParseErr("No recognizable data found in this file."); return; } setParsed({ datasets: res.datasets, label: res.label, source: f.name }); return; } if (res.kind === "collections") { setParsed({ data: res.data, label: res.label, source: f.name }); return; } if (res.kind === "customers") { if (!res.customers.length) { setParseErr("No customer records could be read from this document."); return; } setCustPreview(res.customers); setParsed({ customers: res.customers, label: res.label, source: f.name }); return; } if (res.kind === "text") { // Couldn't structure it — still try the customer extractor, else show a note. setParseErr(`Read the document text but found no recognizable customer fields. ${res.text ? "Preview: " + res.text.slice(0, 120).replace(/\s+/g, " ") + "…" : ""}`); return; } } catch (err) { setParsing(false); setParseErr("Could not parse this file: " + (err && err.message ? err.message : err)); } }; const decryptBlob = async () => { try { const plain = await window.JBVault.decryptWithPass(passText, encBlob); ingestText(plain, file ? file.name : "backup.jbz"); setNeedPass(false); } catch (e) { setParseErr("Incorrect passphrase for this encrypted backup."); } }; // Vehicle inventory ALWAYS goes through DMS.importInventory (match by stock #, // then VIN; blanks keep existing values unless the user chose to clear them). // Datasets already imported from the preview are not imported twice. const importDatasetsSafe = (datasets, m) => { const D = window.DMS; const inv = datasets.filter(d => d.collection === "inventory"); const rest = datasets.filter(d => d.collection !== "inventory"); const counts = {}; const invIssues = []; const parts = []; inv.forEach(d => { if (d.imported) { parts.push(`inventory${d.sheet ? " (" + d.sheet + ")" : ""} already imported from the preview`); return; } const JI = window.JBImporters; const pr = JI && JI.prepareInventoryRows ? JI.prepareInventoryRows(d, { clearBlanks: !!d.clearBlanks, units: D.inventory() }) : { rows: d.rows || [], issues: [] }; const r = D.importInventory(pr.rows, { clearBlanks: !!d.clearBlanks }); invIssues.push(...pr.issues); counts["inventory — new"] = (counts["inventory — new"] || 0) + r.created; counts["inventory — updated"] = (counts["inventory — updated"] || 0) + r.updated; counts["inventory — unchanged"] = (counts["inventory — unchanged"] || 0) + r.unchanged; invIssues.push(...(d.mapIssues || []), ...(r.errors || [])); parts.push(`inventory: ${r.created} new · ${r.updated} updated · ${r.unchanged} unchanged`); }); let ok = inv.length > 0; if (rest.length) { const r = D.importDatasets(rest, { mode: m }); Object.assign(counts, r.counts || {}); parts.push(r.msg); ok = ok || r.ok; } if (invIssues.length) parts.push(`${invIssues.length} inventory issue(s) — see list`); return { ok, msg: parts.join(" · ") || "Nothing recognized to import.", counts, invIssues }; }; // JSON: a full DMS backup is a restore (importBackup). A generic JSON export // with an inventory list merges that list through importInventory instead. const importJsonSafe = (data, m, isBackup) => { const D = window.DMS; if (isBackup || !data || !Array.isArray(data.inventory) || !data.inventory.length) return D.importBackup(data, m); const ir = D.importInventory(data.inventory, {}); const rest = { ...data }; delete rest.inventory; const hasRest = Object.values(rest).some(v => Array.isArray(v) && v.length); const br = hasRest ? D.importBackup(rest, m) : { ok: true, msg: "", counts: {} }; return { ok: true, msg: [`inventory: ${ir.created} new · ${ir.updated} updated · ${ir.unchanged} unchanged`, br.msg].filter(Boolean).join(" · "), counts: { ...(br.counts || {}), "inventory — new": ir.created, "inventory — updated": ir.updated }, invIssues: ir.errors || [] }; }; const resetFile = () => { setFile(null); setParsed(null); setCustPreview(null); setParseErr(""); setNeedPass(false); setEncBlob(null); if (fileRef.current) fileRef.current.value = ""; }; const updateDs = (i, fn) => setParsed(p => { if (!p || !p.datasets) return p; const datasets = p.datasets.map((x, j) => j === i ? fn(x) : x); return { ...p, datasets }; }); const runImport = () => { if (!parsed) return; setStep(4); setDone(false); setProgress(0); setResult(null); const tick = setInterval(() => setProgress(p => Math.min(95, p + 7 + Math.random() * 8)), 120); setTimeout(() => { clearInterval(tick); let r; try { r = parsed.datasets ? importDatasetsSafe(parsed.datasets, mode) : parsed.customers ? window.DMS.importCustomers(parsed.customers) : importJsonSafe(parsed.data, mode, parsed.isBackup); } catch (e) { r = { ok: false, msg: "Import failed: " + (e && e.message ? e.message : e) }; } setProgress(100); setDone(true); setResult(r); window.k2bToast && window.k2bToast(r.ok ? `Import complete — ${r.msg}` : r.msg, r.ok ? "ok" : "warn", 3800); }, 900); }; return (
{/* Stepper */}
{[[1, "Source File"], [2, "Choose Data"], [3, "Review"], [4, "Run Import"]].map(([n, label]) => ( n ? "#D9E8D2" : "#E4E4E4", color: step === n ? "#FFD24A" : step > n ? "var(--k2b-green)" : "#555", border: "1px solid #808080", fontWeight: 700 }}> {step > n ? "✓" : n} {label} {n < 4 && ›} ))} Step {step} of 4
{step === 1 && ( <>
Restore a DMS backup
Restores a full DMS backup file (.json or encrypted .jbz). To bring in a spreadsheet from another system (GL, inventory, customers, vendors, checks) use Import — it maps the columns and shows a preview before anything is saved.
Source DMS: Export path: setPath(e.target.value)} style={{ font: "inherit", padding: "3px 4px" }} />
{/* REAL file picker — now PDF / Word / Excel / image OCR aware */}
Select the backup file
A backup downloaded from Controls → Backup & Restore (.json) or an encrypted backup (.jbz, needs its passphrase). Nothing is written until you confirm in Step 3.
{ const f = e.target.files[0]; onFile(f); }} style={{ display: "none" }} />
fileRef.current && fileRef.current.click()}>📂 Choose File… {file && {file.name}}
{parsing && (
⏳ {parseStat}
First OCR run downloads the recognition engine (~once); larger scans take a little longer.
)} {needPass && (
🔒 Encrypted backup — passphrase: setPassText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") decryptBlob(); }} style={{ font: "inherit", padding: "3px 6px" }} /> Decrypt
)} {parseErr &&
⚠ {parseErr}
} {/* extracted-customer preview */} {custPreview && custPreview.length > 0 && (
✓ Extracted {custPreview.length} customer record{custPreview.length === 1 ? "" : "s"} from {parsed && parsed.source} — review then Next.
{custPreview.slice(0, 50).map((c, i) => ( ))}
NamePhoneCity/STEmail
{[c.firstName, c.lastName].filter(Boolean).join(" ") || "—"} {c.phone || "—"} {[c.city, c.state].filter(Boolean).join(", ") || "—"} {c.email || "—"}
)} {parsed && parsed.datasets && (
✓ Recognized {parsed.datasets.length} dataset{parsed.datasets.length === 1 ? "" : "s"} in {parsed.source} — review the breakdown, then Next.
{parsed.datasets.map((ds, i) => ( ))}
Import as (change if wrong)Source ConfidenceRecords
{ds.sheet || "—"} {ds.confidence === 1 ? "manual" : ds.confidence} {ds.count.toLocaleString()}
)} {parsed && !custPreview && !parsed.datasets && (
✓ Loaded {parsed.label} from {parsed.source} — ready to import.
)}
{parsed && parsed.datasets && parsed.datasets.map((ds, i) => ds.collection === "inventory" && window.JBImporters && window.JBImporters.remapInventory && ( updateDs(i, () => next)} onImported={(r) => updateDs(i, (x) => ({ ...x, imported: true, importResult: r }))} onCancel={resetFile} /> ))} )} {step === 2 && ( <>
Choose which datasets to import
{parsed && parsed.datasets ? ( {parsed.datasets.map((ds, i) => ( ))}
Detected datasetSourceRecords
{ds.label}{ds.imported ? " — already imported" : ds.collection === "inventory" ? " — merged by stock # / VIN" : ""}{ds.sheet || "—"}{ds.count.toLocaleString()}
) : parsed && parsed.customers ? (
Dataset (from your file)Records
customers{parsed.customers.length.toLocaleString()}
) : parsed ? ( {Object.entries(parsed.data).filter(([, v]) => Array.isArray(v)).map(([k, v]) => ( ))} {Object.values(parsed.data).filter(Array.isArray).length === 0 && }
Dataset (from your file)Records
{k}{v.length.toLocaleString()}
Full ledger snapshot (all modules).
) : (
No file loaded yet. Go back to Step 1 and choose your export file.
)}
Import mode
{parsed && parsed.customers ?
Customer records are merged — new people are added and matching phone numbers are updated. Existing data is preserved.
: [["merge", "Merge — add new records, keep existing data (recommended)"], ["replace", "Replace — overwrite the entire ledger with this file"]].map(([k, lbl]) => ( ))}
)} {step === 3 && ( <>
Review
Source: {source} File: {file ? file.name : "— none —"} Contents: {parsed ? parsed.label : "nothing loaded"} Import mode: {mode === "merge" ? "Merge (non-destructive)" : "Replace (overwrite)"} Target: K2B NETWORK SOLUTIONS LLC DMS · Store {upStoreId()}
{mode === "replace" ? <>Heads up: Replace overwrites all current records. Export a backup first if unsure. : <>Safe: Merge only adds records not already present — existing data is preserved.}
)} {step === 4 && ( <>
{done ? "Import complete" : "Running import…"}
{Math.round(progress)}%
[…] reading {file ? file.name : "file"}…
{result && result.counts && Object.entries(result.counts).map(([k, v]) => (
OK {String(v).padStart(6)} {k}
))} {result && (result.invIssues || []).map((x, k) => (
WARN row {x.row} · {x.field}: {x.reason}
))} {done && result &&
── {result.ok ? "DONE. " + result.msg : result.msg}
}
)}
Cancel {step > 1 && step < 4 && setStep(step - 1)}>‹ Back} {step < 3 && setStep(step + 1)}>Next ›} {step === 3 && Start Import} {step === 4 && done && Finish}
); }; /* ─── Vehicle inventory: column mapping + dry-run preview ─────────────── Shown as soon as a file with vehicle inventory is read. Every column is mapped to a DMS field (changeable), the dry run comes from the SAME core function the import uses (DMS.importInventory, dryRun) — existing units are matched by stock #, then VIN; blank cells keep existing data unless the user ticks "Blank cells clear existing values". Every skipped row / field is in the issue list; nothing is dropped silently. */ const InvImportPreview = ({ ds, onChange, onImported, onCancel }) => { const D = window.DMS, JI = window.JBImporters; const [rev, setRev] = React.useState(0); React.useEffect(() => (D && D.subscribe ? D.subscribe(() => setRev(r => r + 1)) : undefined), []); const clearBlanks = !!ds.clearBlanks; const done = ds.importResult || null; // Same row prep for the dry run and the real import (see prepareInventoryRows). const prep = React.useMemo(() => (JI && JI.prepareInventoryRows ? JI.prepareInventoryRows(ds, { clearBlanks, units: D.inventory() }) : { rows: ds.rows || [], issues: [] }), [ds, clearBlanks, rev]); const dry = React.useMemo(() => { try { return D.importInventory(prep.rows, { dryRun: true, clearBlanks }); } catch (e) { return { created: 0, updated: 0, unchanged: 0, plan: [], errors: [{ row: "—", field: "—", reason: "Preview failed: " + (e && e.message) }] }; } }, [prep, clearBlanks]); // The core compares JSON, so 2020 vs "2020", undefined vs "" or 0 count as // "changes". Show only changes a person would call a change. const NUMF = { miles: 1, cost: 1, asking: 1, msrp: 1 }; const same = (f, a, b) => NUMF[f] ? (a == null || a === "" ? 0 : +a) === (b == null || b === "" ? 0 : +b) : String(a == null ? "" : a).trim() === String(b == null ? "" : b).trim(); const realPlan = (r) => (r.plan || []).map(p => { if (p.action !== "update") return p; const ch = {}; Object.keys(p.changes || {}).forEach(f => { if (!same(f, p.changes[f].from, p.changes[f].to)) ch[f] = p.changes[f]; }); return Object.keys(ch).length ? { ...p, changes: ch } : { ...p, action: "unchanged" }; }); const tally = (r) => { const pl = realPlan(r); return { ...r, plan: pl, created: pl.filter(p => p.action === "create").length, updated: pl.filter(p => p.action === "update").length, unchanged: pl.filter(p => p.action === "unchanged").length }; }; const res = tally(done || dry); const issues = [...(ds.mapIssues || []), ...(done ? (done.prepIssues || []) : prep.issues), ...(res.errors || [])].sort((a, b) => (+a.row || 0) - (+b.row || 0)); const planByRow = {}; (res.plan || []).forEach(p => { planByRow[p.row] = p; }); const issuesByRow = {}; issues.forEach(x => { (issuesByRow[x.row] = issuesByRow[x.row] || []).push(x); }); const nRows = (ds.rows || []).length; const skipped = nRows - (res.plan || []).length; const dryT = tally(dry); const toImport = dryT.created + dryT.updated; const fields = (JI && JI.INV_FIELDS) || []; const fieldLabel = Object.fromEntries(fields.map(f => [f.key, f.label])); const mapped = new Set(Object.values(ds.mapping || {}).filter(Boolean)); const KEY6 = ["stock", "vin", "asking", "cost", "miles", "newUsed"]; const missing = KEY6.filter(k => !mapped.has(k)); const sampleOf = (h) => { const r = (ds.raw || []).find(x => String((x || {})[h] ?? "").trim()); return r ? String(r[h]).trim() : ""; }; const setMap = (header, field) => { const next = { ...(ds.mapping || {}) }; if (field) Object.keys(next).forEach(h => { if (h !== header && next[h] === field) next[h] = ""; }); // one column per field next[header] = field; onChange(JI.remapInventory(ds, next)); }; const money = (v) => (v === null || v === undefined || v === "" || Number.isNaN(v)) ? "" : "$" + Number(v).toLocaleString("en-US", { minimumFractionDigits: Number(v) % 1 ? 2 : 0, maximumFractionDigits: 2 }); const miles = (v) => (v === null || v === undefined || v === "" || Number.isNaN(v)) ? "" : Number(v).toLocaleString("en-US"); const runReal = () => { let r; const pr = JI && JI.prepareInventoryRows ? JI.prepareInventoryRows(ds, { clearBlanks, units: D.inventory() }) : { rows: ds.rows || [], issues: [] }; try { r = D.importInventory(pr.rows, { clearBlanks }); } catch (e) { window.k2bToast && window.k2bToast("Import failed: " + (e && e.message), "warn", 4000); return; } r = { ...r, prepIssues: pr.issues }; onImported(r); const nIss = (ds.mapIssues || []).length + pr.issues.length + (r.errors || []).length; const t = tally(r); window.k2bToast && window.k2bToast(`Inventory import — ${t.created} new · ${t.updated} updated · ${t.unchanged} unchanged${nIss ? ` · ${nIss} issue(s) listed` : ""}`, nIss ? "warn" : "ok", 4200); }; const downloadReport = () => { const q = (v) => { const s = String(v == null ? "" : v); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; const lines = [["row", "stock", "result", "field", "detail"].join(",")]; (res.plan || []).forEach(p => { if (p.action === "update") Object.keys(p.changes || {}).forEach(f => lines.push([p.row, p.stock, "updated", f, `${p.changes[f].from ?? ""} → ${p.changes[f].to ?? ""}`].map(q).join(","))); else lines.push([p.row, p.stock, p.action === "create" ? "created" : "unchanged", "", ""].map(q).join(",")); }); issues.forEach(x => lines.push([x.row, ((ds.rows || [])[+x.row - 2] || {}).stock || "", "issue", x.field, x.reason].map(q).join(","))); const blob = new Blob([lines.join("\n")], { type: "text/csv" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `inventory-import-${done ? "result" : "preview"}-${new Date().toISOString().slice(0, 10)}.csv`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }; const cell = { padding: "3px 6px", borderTop: "1px solid #EEE", whiteSpace: "nowrap" }; const hcell = { padding: "4px 6px", textAlign: "left", position: "sticky", top: 0, background: "#F0F0F0", zIndex: 1, whiteSpace: "nowrap" }; const badge = (bg, fg, t) => {t}; const ACTION = { create: badge("#D9F0DE", "#176A38", "NEW"), update: badge("#FFF0CC", "#8A6400", "UPDATE"), unchanged: badge("#ECECEC", "#555", "NO CHANGE"), skipped: badge("#F3D9D9", "#9A2A2A", "SKIPPED"), }; const btn = (primary) => ({ fontWeight: 700, padding: "4px 12px", background: primary ? "#FFE9B0" : undefined }); // One preview cell: the file's value; changed fields highlighted; a blank // file cell on an existing unit shows the value that is KEPT (or CLEARED). const valCell = (f, fileVal, fmt, p, unit, key) => { const blank = fileVal === null || fileVal === undefined || fileVal === "" || Number.isNaN(fileVal); const ch = p && p.action === "update" && p.changes && p.changes[f]; const style = { ...cell, textAlign: fmt === "text" ? "left" : "right", fontFamily: fmt === "text" ? undefined : "'Courier Prime', monospace" }; const show = (v) => fmt === "money" ? money(v) : fmt === "miles" ? miles(v) : String(v ?? ""); if (ch) return {blank ? cleared : show(fileVal)}; if (blank && unit && unit[f] !== undefined && unit[f] !== "" && unit[f] !== null) return {show(unit[f])} kept; if (blank) return —; return {show(fileVal)}; }; return (
🚗 Vehicle inventory — {done ? "import result" : "check the column mapping and preview before importing"} {ds.sheet ? `(${ds.sheet})` : ""} · {nRows} row{nRows === 1 ? "" : "s"} · existing units matched by stock #, then VIN
{/* (a) column mapping */} {!done && (
{(ds.headers || []).map(h => ( ))}
File columnSampleImports into (DMS field)
{h} {sampleOf(h) || "—"}
{(ds.unmapped || []).length > 0 && (
Unmapped columns — not imported: {(ds.unmapped || []).join(", ")}. Pick a DMS field in the dropdown to bring one in.
)} {missing.length > 0 && (
No column found for: {missing.map(k => fieldLabel[k]).join(", ")} — {missing.length === 1 ? "that field is" : "those fields are"} left unchanged on existing units (new units get it blank / $0){missing.includes("newUsed") ? "; new units without New/Used are saved as Used" : ""}.
)}
)} {/* counts */}
{done ? "✓ Imported:" : "Dry run:"} {res.created} new {res.updated} update{res.updated === 1 ? "" : "s"} {res.unchanged} no change {skipped} skipped {issues.length} issue{issues.length === 1 ? "" : "s"} {clearBlanks && · blank cells clear values}
{/* (b) row preview */}
{["Row", "Result", "Stock #", "VIN", "Year / Make / Model", "New/Used", "Miles", "Cost", "Retail", "Notes"].map(h => )} {(ds.rows || []).slice(0, 200).map((row, i) => { const rowNo = i + 2; const n = D._normUnitRow ? D._normUnitRow(row || {}) : row; // what the FILE says (blank → "kept") const p = planByRow[rowNo]; const action = p ? p.action : "skipped"; const unit = p && p.action !== "create" && D._unit ? D._unit(p.stock) : null; const ymm = [n.yr, n.mk, n.mdl].filter(Boolean).join(" "); const rIss = issuesByRow[rowNo] || []; return ( {valCell("vin", n.vin, "text", p, unit, "vin")} {valCell("type", n.type, "text", p, unit, "type")} {valCell("miles", n.miles, "miles", p, unit, "miles")} {valCell("cost", n.cost, "money", p, unit, "cost")} {valCell("asking", n.asking, "money", p, unit, "asking")} ); })} {nRows > 200 && }
{h}
{rowNo} {ACTION[action]} {n.stock || (unit && unit.stock) || —}{ymm || (unit ? {[unit.yr, unit.mk, unit.mdl].filter(Boolean).join(" ")} : —)} {rIss.length ? rIss.map((x, k) =>
{x.field}: {x.reason}
) : p && p.action === "update" ? "changes " + Object.keys(p.changes || {}).map(f => ({ asking: "retail", type: "new/used" }[f] || f)).join(", ") : ""}
… {nRows - 200} more row(s) not shown — all {nRows} are included in the counts, the import and the issue list.
{/* issues */} {issues.length > 0 && (
⚠ {issues.length} row/field issue{issues.length === 1 ? "" : "s"} — {done ? "these were NOT imported as-is" : "fix the file or the mapping, or import and correct afterwards"}
{issues.map((x, k) => ( ))}
RowFieldWhat happened
{x.row}{x.field}{x.reason}
)}
{!done ? (<> {toImport ? `${dryT.created} new unit(s) will be added and ${dryT.updated} existing unit(s) updated. Nothing is written until you click Import.` : "Every row matches what is already in inventory."} ) : (<> ✓ Saved to inventory: {res.created} new · {res.updated} updated · {res.unchanged} unchanged. )}
); }; const th = { padding: "3px 8px", border: "1px solid #B0B0B0", textAlign: "left", fontWeight: 700 }; const td = { padding: "3px 8px", border: "1px solid #D0D0D0" }; /* ─── Auto-upload (existing nightly-feed view) ──────────────── */ const AutoUploadPanel = ({ onClose }) => { const [log, setLog] = React.useState(() => [...UPLOAD_LOG]); const [running, setRunning] = React.useState(false); const logRef = React.useRef(null); const ts = () => { const d = new Date(); const p = (n) => ("0" + n).slice(-2); return `${p(d.getMonth() + 1)}/${p(d.getDate())}/${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; }; const append = (level, msg) => setLog(l => [...l, { ts: ts(), level, msg }]); React.useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [log]); const runNow = () => { if (running) return; setRunning(true); const D = window.DMS; const n = (fn) => { try { return (fn() || []).length; } catch (e) { return 0; } }; const steps = [ ["INFO", "Manual upload run started by operator."], ["INFO", "Connecting to gmds.gm.com:22 (SFTP)…"], ["OK", "Authenticated — store " + upStoreId() + " (" + upStoreName() + ")."], ["OK", `SALES feed: ${D ? n(() => D.deals().filter(d => d.postedToGL)) : 0} posted deal(s) transmitted.`], ["OK", `INVENTORY feed: ${D ? n(D.inventory) : 0} unit(s) transmitted.`], ["OK", `SERVICE feed: ${D ? n(D.repairOrders) : 0} repair order(s) transmitted.`], ["OK", `PARTS feed: ${D ? n(D.parts) : 0} part record(s) transmitted.`], ["OK", `GL feed: ${D ? n(D.journalEntries) : 0} journal entr(ies) transmitted.`], ["INFO", "Disconnected. Upload complete."], ]; let i = 0; const t = setInterval(() => { if (i >= steps.length) { clearInterval(t); setRunning(false); window.DMS && window.DMS.logAudit && window.DMS.logAudit("Nightly Upload (manual)", "All feeds transmitted"); window.k2bToast && window.k2bToast("Manual run complete — all feeds transmitted.", "ok"); return; } append(steps[i][0], steps[i][1]); i++; }, 420); }; const saveLog = () => { const text = log.map(l => `[${l.ts}] ${String(l.level).padEnd(4)} ${l.msg}`).join("\n"); const blob = new Blob([text], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `jbrews-upload-${new Date().toISOString().slice(0, 10)}.log`; a.click(); URL.revokeObjectURL(url); window.k2bToast && window.k2bToast("Log downloaded.", "ok"); }; return (
Next scheduled run: Tonight 03:00 AM {running ? "Running…" : "Run Now"} Connection OK — gmds.gm.com
{log.length === 0 &&
(log empty)
} {log.map((line, i) => (
[{line.ts}]{" "} {line.level.padEnd(4)}{" "} {line.msg}
))}
Close Save Log… { setLog([]); window.k2bToast && window.k2bToast("Log cleared.", "info"); }}>Clear
); }; Object.assign(window, { UploadTool, UPLOAD_LOG, ImportFlow, ImportHistoryView }); /* ─── Export panel ──────────────────────────────────────────── */ const EXPORT_DATASETS = [ { key: "deals", name: "Open & closed deals" }, { key: "customers", name: "Customers" }, { key: "inv", name: "Vehicle inventory" }, { key: "parts", name: "Parts master + inventory" }, { key: "ros", name: "Service repair orders" }, { key: "gl", name: "General Ledger" }, { key: "ap", name: "Accounts Payable" }, { key: "ar", name: "Accounts Receivable" }, { key: "payroll", name: "Payroll history" }, ]; const ExportPanel = ({ onClose }) => { const [picked, setPicked] = React.useState(() => Object.fromEntries(EXPORT_DATASETS.map(d => [d.key, false])) ); const [format, setFormat] = React.useState("csv"); const [dest, setDest] = React.useState("download"); const [running, setRunning] = React.useState(false); const [done, setDone] = React.useState(false); const allOff = !Object.values(picked).some(Boolean); const allOn = Object.values(picked).every(Boolean); const run = async () => { if (allOff) { window.k2bToast && window.k2bToast("Pick at least one dataset.", "warn"); return; } setRunning(true); setDone(false); try { // Build a real backup from the live ledger, limited to picked datasets. const full = window.DMS ? window.DMS.exportBackup() : { data: {} }; const keyMap = { deals: "deals", customers: "arCustomers", inv: "inventory", parts: "parts", ros: "repairOrders", gl: "audit", ap: "payables", ar: "arCustomers", payroll: "audit" }; const picfiles = {}; Object.keys(picked).filter(k => picked[k]).forEach(k => { const dk = keyMap[k]; if (dk && full.data[dk]) picfiles[dk] = full.data[dk]; }); const payload = { ...full, data: Object.keys(picfiles).length ? picfiles : full.data }; let blob, fname; const stamp = new Date().toISOString().slice(0, 10); if (format === "json") { blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); fname = `jbrews-${upStoreId()}-${stamp}.json`; } else if (format === "csv") { const firstList = Object.values(payload.data).find(Array.isArray) || []; const head = firstList.length ? Object.keys(firstList[0]) : []; const lines = [head.join(","), ...firstList.map(r => head.map(h => JSON.stringify(r[h] ?? "")).join(","))]; blob = new Blob([lines.join("\n")], { type: "text/csv" }); fname = `jbrews-${upStoreId()}-${stamp}.csv`; } else { // fixed/xlsx fall back to JSON envelope blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); fname = `jbrews-${upStoreId()}-${stamp}.json`; } // If encryption is on, wrap the backup in a passphrase-protected .jbz. const vaultOn = window.JBVault && window.JBVault.isEnabled() && window.JBVault.isUnlocked(); if (vaultOn && (format === "json" || format === "fixed")) { const pass = window.prompt("Passphrase to protect this backup file (recipient needs it to import):", ""); if (pass) { const enc = await window.JBVault.encryptWithPass(pass, JSON.stringify(payload)); blob = new Blob([enc], { type: "application/octet-stream" }); fname = `jbrews-${upStoreId()}-${stamp}.jbz`; } } if (dest === "download") { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = fname; a.click(); URL.revokeObjectURL(url); } window.DMS && window.DMS.logAudit("Data Export", `${fname} (${Object.keys(payload.data).length} datasets)`); setRunning(false); setDone(true); const n = Object.keys(payload.data).length; window.k2bToast && window.k2bToast(`Export complete — ${n} dataset${n === 1 ? "" : "s"} → ${fname}.`, "ok", 3200); } catch (e) { setRunning(false); window.k2bToast && window.k2bToast("Export failed: " + e.message, "warn"); } }; return (
Export Data
Pull data out of JBREWS in CSV, Excel, fixed-width, or JSON — to your computer, an SFTP server, a network share, or S3.
Datasets to export
setPicked( allOn ? Object.fromEntries(EXPORT_DATASETS.map(d => [d.key, false])) : Object.fromEntries(EXPORT_DATASETS.map(d => [d.key, true])) )}>{allOn ? "Clear All" : "Select All"}
{EXPORT_DATASETS.map(d => ( ))}
Format
{[ ["csv", "CSV", "One file per dataset, comma-separated."], ["xlsx", "Excel (.xlsx)", "One workbook, one sheet per dataset."], ["fixed", "Fixed-width", "Legacy format for old DMS migrations."], ["json", "JSON", "For developer / API integrations."], ].map(([k, label, blurb]) => ( ))}
Destination
{[ ["download", "Download (this browser)", "Browser saves the .zip locally."], ["sftp", "SFTP server", "Push to a remote SFTP path."], ["share", "Network share / mapped drive", "Drop into a Windows network folder."], ["s3", "AWS S3 bucket", "Direct upload to your S3 bucket."], ].map(([k, label, blurb]) => ( ))}
{dest !== "download" && (
{dest === "sftp" && (<> Host User Path )} {dest === "share" && (<> Path )} {dest === "s3" && (<> Bucket Prefix Region )}
)}
Cancel {done && ✓ Export complete.} {running ? "Exporting…" : ( dest === "download" ? `↓ Download ${format.toUpperCase()}` : "▶ Run Export" )}
Schedule it? Switch to the Auto Upload tab to run this export every night automatically.
); };