/* © 2026 K2B NETWORK SOLUTIONS LLC. All rights reserved. */ /* eslint-disable */ // JBREWS DMS — Service ▸ Reports. // Mounted by ServiceDept as the "Reports" tab: . // // Everything here is READ-ONLY aggregation over the shared ledger: // DMS.repairOrders() — the ROs (old flat shape AND the jobs[]/payments[] shape) // DMS.technicians() — tech list (+ clockMTD summary when no punches exist) // DMS.journalEntries() — posted GL; RO entries are found by je.ro / je.roId, else // DMS.jeSource(je) (RO number in the description) // DMS.stdJobs() — standard-job names/categories for the job-type filter // Definitions: // SALE = an RO that is closed AND posted to the GL. Open ROs are estimates // and are shown ONLY under "Open work (estimates — not sales)". // Closed-but-unposted ROs are listed as exceptions, never counted as sales. // Report totals are built from each RO's own components (labor, parts, sublet, // fees, shop supplies, tax); any gap between an RO's job lines and its header // is carried on an explicit "unallocated (RO header)" line so every total // ties back to the ROs, and the ROs tie to their posted journal entries. // ── small utils (all top-level names prefixed sr/SR_ — shared global scope) ── const srNum = (v) => { const n = typeof v === "string" ? parseFloat(v.replace(/[$,]/g, "")) : +v; return isFinite(n) ? n : 0; }; const srHas = (v) => v !== undefined && v !== null && v !== "" && typeof v !== "boolean" && isFinite(typeof v === "string" ? parseFloat(v.replace(/[$,]/g, "")) : +v); const srR2 = (n) => Math.round((+n || 0) * 100) / 100; const srMoney = (n) => (srR2(n) < 0 ? "-$" : "$") + Math.abs(srR2(n)).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const srHrs = (n) => (+n || 0).toFixed(1); const srEsc = (s) => String(s == null ? "" : s).replace(/&/g, "&").replace(//g, ">"); const srNormId = (id) => String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, ""); const srPad = (n) => String(n).padStart(2, "0"); const srDayKey = (d) => d ? d.getFullYear() + "-" + srPad(d.getMonth() + 1) + "-" + srPad(d.getDate()) : ""; const srFmtDay = (d) => d ? srPad(d.getMonth() + 1) + "/" + srPad(d.getDate()) + "/" + d.getFullYear() : "—"; // ISO, Date, epoch, "MM/DD/YY", "MM/DD/YYYY", "YYYY-MM-DD" → Date | null (date-only strings are LOCAL days) const srDate = (v) => { if (v == null || v === "") return null; if (v instanceof Date) return isNaN(v) ? null : v; if (typeof v === "number") { const d = new Date(v); return isNaN(d) ? null : d; } const s = String(v).trim(); let m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2}|\d{4})$/); if (m) { let y = +m[3]; if (y < 100) y += 2000; return new Date(y, +m[1] - 1, +m[2]); } m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (m) return new Date(+m[1], +m[2] - 1, +m[3]); const d = new Date(s); return isNaN(d) ? null : d; }; const srFirst = (...vals) => { for (const v of vals) if (srHas(v)) return srNum(v); return null; }; // ── pay types ── const SR_PAY = [ { k: "customer", label: "Customer pay" }, { k: "warranty", label: "Warranty" }, { k: "contract", label: "Service contract" }, { k: "recon", label: "Internal — recon" }, { k: "courtesy", label: "Courtesy / goodwill" }, { k: "internal", label: "Other internal" }, { k: "other", label: "Other" }, ]; const SR_PAY_LABEL = SR_PAY.reduce((m, p) => (m[p.k] = p.label, m), {}); const srPayKey = (raw, ro) => { const s = String(raw || "").toLowerCase().trim(); if (!s) return "customer"; // legacy default payType if (/contract|\bvsc\b|\besc\b|extended|\bmbi\b|service agreement|maintenance plan/.test(s)) return "contract"; if (/warrant|factory|recall|campaign/.test(s)) return "warranty"; if (/courtesy|goodwill|policy|complimentary|no.?charge/.test(s)) return "courtesy"; if (/recon|make.?ready|get.?ready|\bpdi\b|pre.?delivery|used car|new car|\blot\b|inventory/.test(s)) return "recon"; if (/internal|company|dealer|shop/.test(s)) return (ro && (ro.internalStock || ro.reconStock || ro.stock)) ? "recon" : "internal"; if (/customer|cash|\bcp\b|c\/p|retail/.test(s)) return "customer"; return "other"; }; // ── RO status ── const srStatus = (ro) => { const st = String(ro.status || "").toLowerCase(); if (/void|cancel|deleted/.test(st)) return "void"; if (/closed|posted|invoiced|final/.test(st) || ro.postedToGL === true || ro.posted === true || ro.postedAt) return "closed"; return "open"; }; // ── accounts that carry the SALE side of an RO journal entry ── // Revenue (4xx) plus tax / fee / shop-supply / sublet-sale / card-surcharge // accounts identified by name or by the dealer's account map. Never assets (2xx), // costs/expenses (6xx, 0xx, 1xx), clearing (999) or payables. const srSaleAcctTest = () => { const extra = new Set(); try { const A = (window.DMS && DMS._ACCT) || window.DMS_ACCT || {}; Object.keys(A).forEach(k => { if (/tax|fee|suppl|sublet(?!.*cost)|surcharge|hazmat|env/i.test(k) && !/cost|exp|rec|inv|payoff|floor|^com|commission|deposit|title/i.test(k)) extra.add(String(A[k])); }); const ss = (DMS.serviceSettings && DMS.serviceSettings()) || {}; const walk = (o, depth) => { if (!o || typeof o !== "object" || depth > 3) return; Object.keys(o).forEach(k => { const v = o[k]; if ((typeof v === "string" || typeof v === "number") && /acct|account/i.test(k) && !/cost|exp|rec|inv|cash|bank|clearing|payable(?!.*tax)/i.test(k) && /tax|fee|suppl|sublet|surcharge|card|labor|parts|sale|income|revenue/i.test(k)) extra.add(String(v)); else if (v && typeof v === "object") walk(v, depth + 1); }); }; walk(ss, 0); } catch (e) {} const nameRe = /(sales tax|tax payable|tax collected|shop suppl|fee income|fees? earned|service fee|card (fee|surcharge)|surcharge|convenience fee|sublet sale|hazard|environment|misc(ellaneous)? (sale|income))/i; return (acct) => { const a = String(acct || ""); if (!a || a === "999") return false; if (/^[0126]/.test(a)) return false; if (/^4/.test(a)) return true; if (extra.has(a)) return true; let nm = ""; try { nm = (DMS.acctName && DMS.acctName(a)) || ""; } catch (e) {} return nameRe.test(nm); }; }; // ── JE → RO index ── const srJeRoIds = (je) => { const ids = []; [je.ro, je.roId, je.repairOrder].forEach(x => { if (x) ids.push(srNormId(typeof x === "object" ? x.id : x)); }); if (!ids.length) { let src = null; try { src = DMS.jeSource ? DMS.jeSource(je) : null; } catch (e) {} if (src && src.kind === "ro") ids.push(srNormId(src.id)); else { const m = String((je.desc || "") + " " + (je.ref || "")).match(/\b(RO-?\d+)\b/i); if (m) ids.push(srNormId(m[1])); } } return ids; }; // ── time-clock data (only if the Service/Payroll build provides punches) ── const SR_PUNCH_NAMES = ["timePunches", "techTimePunches", "techPunches", "timeClock", "techClock", "clockPunches", "techTime", "timeEntries"]; const srTimePunches = () => { for (const n of SR_PUNCH_NAMES) { try { const f = DMS[n]; if (typeof f === "function") { const r = f(); if (Array.isArray(r) && r.length) return { rows: r, src: "DMS." + n + "()" }; } } catch (e) {} } try { const r = DMS._read && DMS._read(s => { for (const k of SR_PUNCH_NAMES) if (Array.isArray(s[k]) && s[k].length) return { rows: s[k], src: "state." + k }; return null; }); if (r) return r; } catch (e) {} return null; }; const srPunchHours = (p, from, to) => { if (/lunch|break|meal/i.test(String(p.type || p.kind || ""))) return null; const inD = srDate(p.in || p.clockIn || p.inAt || p.start || p.startAt || p.at); const outD = srDate(p.out || p.clockOut || p.outAt || p.end || p.endAt); const day = srDate(p.date || p.day) || inD; if (!day) return null; const k = srDayKey(day); if (k < from || k > to) return 0; if (srHas(p.hours)) return srNum(p.hours); if (!inD) return null; return Math.max(0, ((outD || new Date()) - inD) / 3600000); }; // ── Normalize one RO (old flat shape + new jobs[] shape) ── const srNormalizeRO = (ro, ctx) => { const status = srStatus(ro); const roPay = srPayKey(ro.payType || ro.payer || ro.type, ro); const lines = []; const useJobs = Array.isArray(ro.jobs) && ro.jobs.length > 0; const src = useJobs ? ro.jobs : (Array.isArray(ro.ops) ? ro.ops : []); const srcName = useJobs ? "jobs[]" : "ops[]"; const techNameOf = (id, fallback) => { const t = ctx.techMap[id]; return t ? t.name : (fallback || id || "— unassigned —"); }; src.forEach((j, ix) => { if (!j) return; const decl = /declin/i.test(String(j.approval || j.status || "")) || j.declined === true; const std = ctx.stdMap[j.stdJobId || j.stdJob || j.stdId || ""] || null; const op = j.opCode || j.op || j.laborOp || j.code || (std && (std.opCode || std.op || std.code)) || ""; const stdName = std ? (std.name || std.desc || std.title || std.id) : (j.stdJobName || ""); const cat = j.category || j.jobType || j.jobCategory || (std && (std.category || std.cat)) || ""; const pay = (j.payType || j.payer) ? srPayKey(j.payType || j.payer, ro) : roPay; const hours = srFirst(j.hours, j.billedHours, j.flagHours, j.flaggedHours, j.soldHours, j.hrs) || 0; const labor = srFirst(j.laborSale, j.labor, j.laborAmount, j.lab, j.amount, j.price) || 0; let parts = srFirst(j.partsSale, j.partsTotal); if (parts == null) { const own = j.parts || j.partsLines; const pl = Array.isArray(own) ? own : (Array.isArray(ro.partsLines) && j.id != null ? ro.partsLines.filter(p => p && p.jobId != null && String(p.jobId) === String(j.id)) : []); parts = pl.reduce((s, p) => s + srNum(p.sale != null ? p.sale : p.price) * (srHas(p.qty) ? srNum(p.qty) : 1), 0); } const sublet = srFirst(j.subletSale, j.sublet) || (Array.isArray(j.sublets) ? j.sublets.reduce((s, x) => s + srNum(x.sale), 0) : 0); const base = { roId: ro.id, desc: j.desc || j.description || j.name || stdName || op || ("Job " + (ix + 1)), op: op || "(no op code)", std: stdName || "(not a standard job)", cat: cat || "(uncategorized)", pay, decl, hourSrc: "RO " + srcName + " hours" }; if (decl) { lines.push({ ...base, techId: j.techId || ro.techId || "", techName: techNameOf(j.techId || ro.techId, ro.tech), hours: 0, labor: 0, parts: 0, sublet: 0, declinedLabor: labor }); return; } const splits = (Array.isArray(j.techs) && j.techs.length && typeof j.techs[0] === "object") ? j.techs : (Array.isArray(j.splits) && j.splits.length ? j.splits : null); if (splits) { const tot = splits.reduce((s, x) => s + (srHas(x.hours) ? srNum(x.hours) : srHas(x.pct) ? srNum(x.pct) : 1), 0) || 1; splits.forEach(x => { const w = (srHas(x.hours) ? srNum(x.hours) : srHas(x.pct) ? srNum(x.pct) : 1) / tot; lines.push({ ...base, techId: x.techId || x.id || "", techName: techNameOf(x.techId || x.id, x.name), hours: srHas(x.hours) ? srNum(x.hours) : hours * w, labor: labor * w, parts: parts * w, sublet: sublet * w }); }); } else { const tid = j.techId || j.tech_id || ro.techId || ""; lines.push({ ...base, techId: tid, techName: techNameOf(tid, j.techName || (typeof j.tech === "string" ? j.tech : "") || ro.tech), hours, labor, parts, sublet }); } }); // Header components (authoritative when present) const live = lines.filter(l => !l.decl); const sumL = (f) => live.reduce((s, l) => s + l[f], 0); const hdrLabor = srFirst(ro.laborSale, ro.laborTotal, typeof ro.labor !== "object" ? ro.labor : null); const hdrParts = srFirst(ro.partsSale, ro.partsTotal, typeof ro.parts !== "object" ? ro.parts : null); const hdrSublet = srFirst(ro.subletSale, typeof ro.sublet !== "object" ? ro.sublet : null) != null ? srFirst(ro.subletSale, typeof ro.sublet !== "object" ? ro.sublet : null) : (Array.isArray(ro.sublets) ? ro.sublets.reduce((s, x) => s + srNum(x.sale), 0) : null); const hdrHours = srFirst(ro.flagHours, ro.billedHours, ro.hours); const labor = hdrLabor != null ? hdrLabor : sumL("labor"); const parts = hdrParts != null ? hdrParts : sumL("parts"); const sublet = hdrSublet != null ? hdrSublet : sumL("sublet"); // RO-level parts (old shape keeps partsLines at the RO, not per job) const gapP = srR2(parts - sumL("parts")); if (Math.abs(gapP) > 0.004) { if (live.length === 1) live[0].parts += gapP; else lines.push({ roId: ro.id, desc: "RO-level parts (not tied to a job)", op: "(RO-level parts)", std: "(RO-level parts)", cat: "(RO-level parts)", pay: roPay, techId: ro.techId || "", techName: techNameOf(ro.techId, ro.tech), hours: 0, labor: 0, parts: gapP, sublet: 0, hourSrc: "—", plug: true }); } const gapL = srR2(labor - sumL("labor")), gapS = srR2(sublet - sumL("sublet")); const lineHours = sumL("hours"); const gapH = (hdrHours != null && lineHours < 0.001) ? hdrHours : 0; // only when lines carry no hours if (Math.abs(gapL) > 0.004 || Math.abs(gapS) > 0.004 || gapH > 0) { if (!live.length) lines.push({ roId: ro.id, desc: ro.concern || "RO header (no job lines)", op: "(no job lines)", std: "(not a standard job)", cat: "(uncategorized)", pay: roPay, techId: ro.techId || "", techName: techNameOf(ro.techId, ro.tech), hours: gapH, labor: gapL, parts: 0, sublet: gapS, hourSrc: "RO header flagHours" }); else if (gapH > 0 && Math.abs(gapL) <= 0.004 && Math.abs(gapS) <= 0.004 && live.length === 1) { live[0].hours = gapH; live[0].hourSrc = "RO header flagHours"; } else lines.push({ roId: ro.id, desc: "Unallocated (RO header vs. job lines)", op: "(unallocated)", std: "(unallocated)", cat: "(unallocated)", pay: roPay, techId: ro.techId || "", techName: techNameOf(ro.techId, ro.tech), hours: gapH, labor: gapL, parts: 0, sublet: gapS, hourSrc: gapH ? "RO header flagHours" : "—", plug: true }); } // Fees (RO level) const hasShop = [ro.shopSupplies, ro.shopSupply, ro.suppliesFee, ro.shopFee].some(srHas); const hasCard = [ro.cardFee, ro.cardFees, ro.cardSurcharge, ro.surcharge, ro.convenienceFee].some(srHas); let shop = srFirst(ro.shopSupplies, ro.shopSupply, ro.suppliesFee, ro.shopFee) || 0; let card = srFirst(ro.cardFee, ro.cardFees, ro.cardSurcharge, ro.surcharge, ro.convenienceFee) || 0; let other = (srFirst(ro.otherFees) || 0) + (srFirst(ro.hazmat) || 0) + (srFirst(ro.envFee) || 0) + (srFirst(ro.miscCharges) || 0); let cardFromFees = false; const classify = (name, amt) => { const n = String(name || "").toLowerCase(); if (/suppl/.test(n)) { if (!hasShop) shop += amt; } else if (/card|surcharge|convenience/.test(n)) { if (!hasCard) { card += amt; cardFromFees = true; } } else if (/tax/.test(n)) { /* taxes carried in ro.tax */ } else other += amt; }; if (Array.isArray(ro.fees)) ro.fees.forEach(f => f && classify(f.desc || f.label || f.name || f.type || f.code, srNum(srFirst(f.amount, f.amt, f.sale, f.value)))); else if (ro.fees && typeof ro.fees === "object") Object.keys(ro.fees).forEach(k => srHas(ro.fees[k]) && classify(k, srNum(ro.fees[k]))); else if (srHas(ro.fees)) other += srNum(ro.fees); // ServiceCore model: the card fee rides on each live (non-void) payment (p.fee; refunds carry a negative fee) let payFees = 0; if (!hasCard && !cardFromFees && Array.isArray(ro.payments)) payFees = ro.payments.filter(p => p && !p.voided).reduce((s, p) => s + (srFirst(p.fee, p.surcharge, p.cardFee) || 0), 0); card += payFees; const discount = -Math.abs(srFirst(ro.discount, ro.discountAmt, ro.discountTotal) || 0); const tax = srFirst(ro.tax, ro.salesTax, ro.taxTotal) || 0; const comp = { labor: srR2(labor), parts: srR2(parts), sublet: srR2(sublet), shop: srR2(shop), card: srR2(card), other: srR2(other), discount: srR2(discount), tax: srR2(tax) }; comp.fees = srR2(comp.shop + comp.card + comp.other); comp.total = srR2(comp.labor + comp.parts + comp.sublet + comp.fees + comp.discount + comp.tax); const hdrTotal = srFirst(ro.total, ro.grandTotal, ro.invoiceTotal); // dates + GL const jes = ctx.jeByRo[srNormId(ro.id)] || []; const firstPost = jes.filter(j => !j.reverses).map(j => srDate(j.date)).filter(Boolean).sort((a, b) => a - b)[0] || null; const posted = status === "closed" && (ro.postedToGL === true || ro.posted === true || !!ro.postedAt || jes.length > 0); const dates = { open: srDate(ro.opened || ro.openedAt || ro.openDate || ro.created || ro.createdAt), close: srDate(ro.closed || ro.closedAt || ro.closeDate || ro.cashedOutAt || (ro.stageHist && ro.stageHist["Cashed Out"])), post: srDate(ro.postedAt || ro.postDate || ro.glDate) || firstPost, }; const glNet = srR2(jes.reduce((s, je) => s + (je.lines || []).reduce((t, l) => t + (ctx.isSaleAcct(l.acct) ? srNum(l.cr) - srNum(l.dr) : 0), 0), 0)); const livePays = Array.from(new Set(lines.filter(l => !l.decl).map(l => l.pay))); const payLabel = livePays.length > 1 ? "Mixed (" + livePays.map(k => SR_PAY_LABEL[k]).join(", ") + ")" : SR_PAY_LABEL[livePays[0] || roPay]; return { ro, id: ro.id, status, posted, pay: roPay, payLabel, lines, comp, hdrTotal: hdrTotal != null ? srR2(hdrTotal + payFees) : null, dates, jes, glNet, customer: ro.customer || ro.customerName || "", vehicle: ro.vehicle || "", techName: ro.tech || "" }; }; // ── date presets ── const srPresetRange = (preset, custom) => { const now = new Date(); const t = new Date(now.getFullYear(), now.getMonth(), now.getDate()); if (preset === "today") return [srDayKey(t), srDayKey(t)]; if (preset === "week") { const d = new Date(t); d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); return [srDayKey(d), srDayKey(t)]; } if (preset === "lastmonth") return [srDayKey(new Date(t.getFullYear(), t.getMonth() - 1, 1)), srDayKey(new Date(t.getFullYear(), t.getMonth(), 0))]; if (preset === "custom") { const c = custom || {}; const a = c.from || srDayKey(t), b = c.to || srDayKey(t); return a <= b ? [a, b] : [b, a]; } return [srDayKey(new Date(t.getFullYear(), t.getMonth(), 1)), srDayKey(t)]; // mtd }; const SR_BASIS_LABEL = { open: "RO open date", close: "RO close date", post: "GL posting date" }; const SR_JOBDIM_LABEL = { op: "Labor op", std: "Standard job", cat: "Category" }; // ── aggregation (pure; used by the UI and by probes via window.srServiceReport) ── const srBuildReport = (f) => { const ros = (DMS.repairOrders && DMS.repairOrders()) || []; const techs = (DMS.technicians && DMS.technicians()) || []; const techMap = {}; techs.forEach(t => { techMap[t.id] = t; }); const stdMap = {}; try { ((DMS.stdJobs && DMS.stdJobs()) || []).forEach(s => { if (s && s.id) stdMap[s.id] = s; }); } catch (e) {} const isSaleAcct = srSaleAcctTest(); const jeByRo = {}; ((DMS.journalEntries && DMS.journalEntries()) || []).forEach(je => { if (je) srJeRoIds(je).forEach(id => { (jeByRo[id] = jeByRo[id] || []).push(je); }); }); const ctx = { techMap, stdMap, isSaleAcct, jeByRo }; const norm = ros.filter(r => r && r.id).map(r => srNormalizeRO(r, ctx)); const [from, to] = srPresetRange(f.preset, f.custom); const inRange = (d) => { const k = srDayKey(d); return !!k && k >= from && k <= to; }; const techSet = new Set(f.techs || []), paySet = new Set(f.pays || []); const jobDim = f.jobDim || "op", jobVal = f.jobVal || ""; const lineFilter = techSet.size > 0 || paySet.size > 0 || !!jobVal; const lineOk = (l) => !l.decl && (!techSet.size || techSet.has(l.techId)) && (!paySet.size || paySet.has(l.pay)) && (!jobVal || l[jobDim] === jobVal); const sales = norm.filter(n => n.status === "closed" && n.posted && inRange(n.dates[f.basis])); const salesIn = lineFilter ? sales.filter(n => n.lines.some(lineOk)) : sales; const unposted = norm.filter(n => n.status === "closed" && !n.posted); const noDate = norm.filter(n => n.status === "closed" && n.posted && !n.dates[f.basis]); const openWork = norm.filter(n => n.status === "open" && (!n.dates.open || srDayKey(n.dates.open) <= to)) .filter(n => !lineFilter || n.lines.some(lineOk)); // buckets carry { v, by:{roId:amt} } for drill-down const B = () => ({ v: 0, by: {} }); const add = (b, roId, amt) => { b.v += (amt || 0); b.by[roId] = (b.by[roId] || 0) + (amt || 0); }; const touch = (b, roId) => { if (!(roId in b.by)) b.by[roId] = 0; }; // (1) technicians const punches = srTimePunches(); const curMTD = srPresetRange("mtd"); const isMTD = from === curMTD[0] && to === curMTD[1]; const techRows = {}; const trow = (id, name) => techRows[id || "_none"] = techRows[id || "_none"] || { id: id || "", name: name || "— unassigned —", billed: B(), labor: B(), openHrs: B(), ros: B(), clocked: null, clockSrc: "", lineSrc: new Set() }; techs.forEach(t => trow(t.id, t.name)); salesIn.forEach(n => n.lines.forEach(l => { if (!lineOk(l)) return; const r = trow(l.techId, l.techName); add(r.billed, n.id, l.hours); add(r.labor, n.id, l.labor); touch(r.ros, n.id); if (l.hourSrc && l.hourSrc !== "—") r.lineSrc.add(l.hourSrc); })); openWork.forEach(n => n.lines.forEach(l => { if (!lineOk(l)) return; add(trow(l.techId, l.techName).openHrs, n.id, l.hours); })); Object.values(techRows).forEach(r => { if (punches) { const mine = punches.rows.filter(p => p && String(p.techId || p.tech || p.employeeId || p.empId || "") === r.id); const hrs = mine.map(p => srPunchHours(p, from, to)).filter(h => h != null); r.clocked = srR2(hrs.reduce((s, h) => s + h, 0)); r.clockSrc = "Time punches (" + punches.src + ")"; } else { const t = techMap[r.id]; const hasMTD = t && srHas(t.clockMTD) && srNum(t.clockMTD) > 0; if (hasMTD && isMTD) { r.clocked = srNum(t.clockMTD); r.clockSrc = "Tech record clockMTD — month-to-date summary, not from punches"; } else r.clockSrc = hasMTD ? "No time-clock punches (tech clockMTD summary covers month-to-date only)" : "No time-clock data"; } }); let techList = Object.values(techRows); if (techSet.size) techList = techList.filter(r => techSet.has(r.id)); techList = techList.filter(r => techMap[r.id] || r.billed.v || r.openHrs.v || Object.keys(r.ros.by).length); // (2) job type × pay type, (3) totals, (5) pay types const jt = {}; const tot = { labor: B(), parts: B(), sublet: B(), shop: B(), card: B(), other: B(), discount: B(), tax: B(), total: B(), hours: B() }; const pay = {}; SR_PAY.forEach(p => pay[p.k] = { labor: B(), parts: B(), sublet: B(), fees: B(), tax: B(), total: B(), hours: B(), ros: B() }); salesIn.forEach(n => { n.lines.forEach(l => { if (!lineOk(l)) return; const k = l[jobDim]; const row = jt[k] = jt[k] || { key: k, labor: B(), parts: B(), sublet: B(), hours: B(), pay: {} }; const pc = row.pay[l.pay] = row.pay[l.pay] || { labor: B(), parts: B() }; add(row.labor, n.id, l.labor); add(row.parts, n.id, l.parts); add(row.sublet, n.id, l.sublet); add(row.hours, n.id, l.hours); add(pc.labor, n.id, l.labor); add(pc.parts, n.id, l.parts); add(tot.labor, n.id, l.labor); add(tot.parts, n.id, l.parts); add(tot.sublet, n.id, l.sublet); add(tot.hours, n.id, l.hours); const P = pay[l.pay]; add(P.labor, n.id, l.labor); add(P.parts, n.id, l.parts); add(P.sublet, n.id, l.sublet); add(P.hours, n.id, l.hours); add(P.total, n.id, l.labor + l.parts + l.sublet); touch(P.ros, n.id); }); if (!lineFilter) { const c = n.comp; add(tot.shop, n.id, c.shop); add(tot.card, n.id, c.card); add(tot.other, n.id, c.other); add(tot.discount, n.id, c.discount); add(tot.tax, n.id, c.tax); add(tot.total, n.id, c.total); // RO-level fees/tax/discount belong to the RO's pay type const P = pay[n.pay]; add(P.fees, n.id, c.fees + c.discount); add(P.tax, n.id, c.tax); add(P.total, n.id, c.fees + c.discount + c.tax); touch(P.ros, n.id); } else { add(tot.total, n.id, n.lines.filter(lineOk).reduce((s, l) => s + l.labor + l.parts + l.sublet, 0)); } }); // round buckets once const rnd = (b) => { b.v = srR2(b.v); Object.keys(b.by).forEach(k => b.by[k] = srR2(b.by[k])); }; Object.values(tot).forEach(rnd); Object.values(pay).forEach(P => Object.values(P).forEach(rnd)); // (4) counts const roCount = salesIn.length; const reportTotal = srR2(tot.total.v); const counts = { ros: roCount, avg: roCount ? reportTotal / roCount : 0, hoursPerRO: roCount ? tot.hours.v / roCount : 0, laborPerRO: roCount ? tot.labor.v / roCount : 0, elr: tot.hours.v ? tot.labor.v / tot.hours.v : 0, openCount: openWork.length }; // (6) reconciliation — always the full ROs const rec = salesIn.map(n => ({ id: n.id, n, total: n.comp.total, gl: n.glNet, diff: srR2(n.comp.total - n.glNet), hdrDiff: n.hdrTotal != null ? srR2(n.comp.total - n.hdrTotal) : 0, jes: n.jes.map(j => j.id) })); const recTotal = srR2(rec.reduce((s, r) => s + r.total, 0)); const glTotal = srR2(rec.reduce((s, r) => s + r.gl, 0)); // open work (estimates) const est = openWork.map(n => { let appr = null; try { if (DMS.roEstimateTotals && (n.ro.ops || []).some(o => o && o.approval)) appr = DMS.roEstimateTotals(n.id); } catch (e) {} return { id: n.id, n, est: n.comp.total, hours: n.lines.filter(l => !l.decl).reduce((s, l) => s + l.hours, 0), appr }; }); const techOpts = new Map(); techs.forEach(t => techOpts.set(t.id, t.name)); norm.forEach(n => n.lines.forEach(l => { if (l.techId && !techOpts.has(l.techId)) techOpts.set(l.techId, l.techName); })); return { from, to, basis: f.basis, lineFilter, jobDim, salesIn, sales, unposted, noDate, openWork, techList, jt: Object.values(jt).sort((a, b) => (b.labor.v + b.parts.v) - (a.labor.v + a.parts.v)), tot, pay, counts, rec, recTotal, glTotal, glDiff: srR2(recTotal - glTotal), reportTotal, est, isMTD, punches: !!punches, norm, jobKeys: Array.from(new Set(norm.flatMap(n => n.lines.map(l => l[jobDim])))).sort(), techOpts: Array.from(techOpts.entries()) }; }; // ── CSV + print ── const srCsvCell = (v) => { const s = String(v == null ? "" : v); return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; const srCsv = (rows) => rows.map(r => r.map(srCsvCell).join(",")).join("\r\n"); const srDownload = (name, text) => { try { const url = URL.createObjectURL(new Blob([text], { type: "text/csv" })); const a = document.createElement("a"); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1500); } catch (e) { window.k2bToast && window.k2bToast("Download failed: " + e.message, "warn"); } }; // Section tables as plain rows (shared by CSV export and Print) const srSections = (R) => { const basis = SR_BASIS_LABEL[R.basis]; const f2 = (n) => srR2(n).toFixed(2); const lf = R.lineFilter; const S = []; S.push({ key: "techs", title: "1. Technician hours", basis: "Billed/flagged hours: " + basis + " of posted ROs, source RO job lines · Clocked hours: time-clock punch date", head: ["Tech", "Clocked hrs", "Clocked source", "Billed/flagged hrs", "Billed source", "Productivity %", "Labor sales", "Eff. labor rate", "ROs", "Hrs on open ROs (not billed)"], rows: R.techList.map(t => [t.name + (t.id ? " (" + t.id + ")" : ""), t.clocked == null ? "n/a" : f2(t.clocked), t.clockSrc, f2(t.billed.v), Array.from(t.lineSrc).join("; ") || "RO job lines", t.clocked ? Math.round(t.billed.v / t.clocked * 100) + "%" : "n/a", f2(t.labor.v), t.billed.v ? f2(t.labor.v / t.billed.v) : "n/a", Object.keys(t.ros.by).length, f2(t.openHrs.v)]) }); S.push({ key: "jobs", title: "2. Sales by " + SR_JOBDIM_LABEL[R.jobDim].toLowerCase() + " × pay type", basis: basis + " · posted ROs · source: RO job lines", head: [SR_JOBDIM_LABEL[R.jobDim]].concat(SR_PAY.flatMap(p => [p.label + " labor", p.label + " parts"])).concat(["Sublet", "Hours", "Total labor", "Total parts"]), rows: R.jt.map(r => [r.key].concat(SR_PAY.flatMap(p => { const c = r.pay[p.k]; return [c ? f2(c.labor.v) : "0.00", c ? f2(c.parts.v) : "0.00"]; })) .concat([f2(r.sublet.v), f2(r.hours.v), f2(r.labor.v), f2(r.parts.v)])) }); const T = R.tot, na = "n/a (RO-level; clear line filters)"; S.push({ key: "totals", title: "3. Service sales totals", basis: basis + " · closed & posted ROs only (estimates excluded)", head: ["Component", "Amount"], rows: [["Labor", f2(T.labor.v)], ["Parts", f2(T.parts.v)], ["Sublet", f2(T.sublet.v)], ["Shop supplies", lf ? na : f2(T.shop.v)], ["Card fees / surcharges", lf ? na : f2(T.card.v)], ["Other fees", lf ? na : f2(T.other.v)], ["Discounts", lf ? na : f2(T.discount.v)], ["Tax", lf ? na : f2(T.tax.v)], [lf ? "TOTAL (matching job lines)" : "TOTAL SERVICE SALES", f2(T.total.v)]] }); const C = R.counts; S.push({ key: "counts", title: "4. RO counts & averages", basis: basis + " · closed & posted ROs", head: ["Measure", "Value"], rows: [["Posted ROs", C.ros], ["Average RO $ (posted)", f2(C.avg)], ["Billed hours per RO", C.hoursPerRO.toFixed(2)], ["Labor $ per RO", f2(C.laborPerRO)], ["Effective labor rate (labor / billed hrs)", f2(C.elr)], ["Open ROs (estimates, not sales)", C.openCount]] }); S.push({ key: "pay", title: "5. Pay-type breakdown", basis: basis + " · posted ROs · RO-level fees/tax follow the RO's pay type", head: ["Pay type", "ROs", "Hours", "Labor", "Parts", "Sublet", "Fees", "Tax", "Total"], rows: SR_PAY.map(p => { const P = R.pay[p.k]; return [p.label, Object.keys(P.ros.by).length, f2(P.hours.v), f2(P.labor.v), f2(P.parts.v), f2(P.sublet.v), lf ? "n/a" : f2(P.fees.v), lf ? "n/a" : f2(P.tax.v), f2(P.total.v)]; }) .concat([["TOTAL", C.ros, f2(T.hours.v), f2(T.labor.v), f2(T.parts.v), f2(T.sublet.v), lf ? "n/a" : f2(T.shop.v + T.card.v + T.other.v + T.discount.v), lf ? "n/a" : f2(T.tax.v), f2(T.total.v)]]) }); S.push({ key: "recon", title: "6. Reconciliation — ROs / report / GL", basis: basis + " · full RO totals · GL = net credits to sale-side accounts on each RO's posted journal entries", head: ["RO", "Customer", "Pay type", "Opened", "Closed", "Posted", "RO total", "GL (posted)", "Difference", "Journal entries"], rows: R.rec.map(r => [r.id, r.n.customer, r.n.payLabel, srFmtDay(r.n.dates.open), srFmtDay(r.n.dates.close), srFmtDay(r.n.dates.post), f2(r.total), f2(r.gl), f2(r.diff), r.jes.join(" ")]) .concat([["TOTAL", R.rec.length + " ROs", "", "", "", "", f2(R.recTotal), f2(R.glTotal), f2(R.glDiff), ""]]) }); S.push({ key: "open", title: "Open work (estimates — not sales)", basis: "RO open date on/before " + R.to + " · still open · not included in any sales figure", head: ["RO", "Customer", "Pay type", "Opened", "Tech", "Est. hours", "Estimate total (not a sale)"], rows: R.est.map(e => [e.id, e.n.customer, e.n.payLabel, srFmtDay(e.n.dates.open), e.n.techName, e.hours.toFixed(1), f2(e.est)]) }); return S; }; const srSectionHtml = (s) => `

${srEsc(s.title)}

${srEsc(s.basis)}
${s.head.map(h => ``).join("")}${s.rows.map(r => `${r.map(c => ``).join("")}`).join("") || ``}
${srEsc(h)}
${srEsc(c)}
No rows
`; const srPrintHtml = (R, only) => { const secs = srSections(R).filter(s => !only || s.key === only); return `${window.dealerHeadHtml ? window.dealerHeadHtml() : ""}
SERVICE REPORTS
Range ${srEsc(R.from)} to ${srEsc(R.to)} · Date basis: ${srEsc(SR_BASIS_LABEL[R.basis])}${R.lineFilter ? " · tech/job/pay line filters active" : ""} · Sales = closed & posted ROs only; open ROs are estimates, listed separately, and are not sales.
${secs.map(srSectionHtml).join("")}`; }; // ── UI ── const srTh = { textAlign: "left", padding: "5px 8px", fontSize: 11, fontWeight: 700, color: "var(--jb-text-dim, #555)", borderBottom: "1px solid var(--jb-border, #C0C0C0)", whiteSpace: "nowrap", background: "var(--jb-surface-2, #F0F0F0)" }; const srThR = { ...srTh, textAlign: "right" }; const srTd = { padding: "4px 8px", fontSize: 12, borderBottom: "1px dotted var(--jb-border, #DDD)", color: "var(--jb-text, #1A1A1A)" }; const srTdR = { ...srTd, textAlign: "right", fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap" }; const srInp = { fontSize: 12, padding: "3px 6px", border: "1px solid var(--jb-border-strong, #808080)", borderRadius: 4, background: "var(--jb-bg, #FFF)", color: "var(--jb-text, #1A1A1A)" }; const srMuted = { color: "var(--jb-text-muted, #999)" }; const SrCell = ({ b, fmt, onDrill, title, strong }) => { const v = b ? b.v : 0; const txt = fmt === "h" ? srHrs(v) : fmt === "n" ? String(v) : srMoney(v); const n = b ? Object.keys(b.by).length : 0; if (!n || (fmt !== "n" && Math.abs(v) < 0.005)) return {txt}; return { e.preventDefault(); onDrill(title, b, fmt); }} style={{ color: "var(--jb-blue, #1B66A8)", textDecoration: "none", borderBottom: "1px dotted currentColor", fontWeight: strong ? 700 : 400 }}>{txt}; }; const SrSection = ({ id, title, basis, note, onCsv, onPrint, children }) => (
{title}
{basis}
{onCsv && } {onPrint && }
{note &&
{note}
}
{children}
); const ServiceReports = ({ go }) => { const [, force] = React.useReducer(n => n + 1, 0); React.useEffect(() => (window.DMS && DMS.subscribe ? DMS.subscribe(force) : undefined), []); const [preset, setPreset] = React.useState("mtd"); const [custom, setCustom] = React.useState(() => { const r = srPresetRange("mtd"); return { from: r[0], to: r[1] }; }); const [basis, setBasis] = React.useState("close"); const [techs, setTechs] = React.useState([]); const [pays, setPays] = React.useState([]); const [jobDim, setJobDim] = React.useState("op"); const [jobVal, setJobVal] = React.useState(""); const [drill, setDrill] = React.useState(null); // {title, rows:[{id, amt}], fmt} const [roView, setRoView] = React.useState(null); // RO id const [techOpen, setTechOpen] = React.useState(false); const [payOpen, setPayOpen] = React.useState(false); let R = null, err = null; try { R = srBuildReport({ preset, custom, basis, techs, pays, jobDim, jobVal }); } catch (e) { err = e; } if (err || !R) return
Service reports could not be built: {String((err && err.message) || err)}
; const secs = srSections(R); const secBy = {}; secs.forEach(s => secBy[s.key] = s); const stamp = R.from + "_" + R.to; const csv = (k) => () => { const s = secBy[k]; srDownload(`service-${k}-${stamp}.csv`, srCsv([[s.title], ["Range", R.from, R.to, "Basis", SR_BASIS_LABEL[R.basis]], s.head].concat(s.rows))); }; const print = (k) => () => { if (!window.k2bPrint) { window.k2bToast && window.k2bToast("Printing isn't available on this station.", "warn"); return; } window.k2bPrint(srPrintHtml(R, k), { title: "Service Reports" }); }; const onDrill = (title, b, fmt) => setDrill({ title, fmt, rows: Object.keys(b.by).map(id => ({ id, amt: b.by[id] })) }); const normBy = {}; R.norm.forEach(n => normBy[n.id] = n); const basisLbl = SR_BASIS_LABEL[basis]; const lf = R.lineFilter; const toggle = (arr, set, v) => set(arr.includes(v) ? arr.filter(x => x !== v) : arr.concat([v])); const naCell = n/a; const jtPays = SR_PAY.filter(p => R.jt.some(r => r.pay[p.k])); const jtCols = jtPays.length ? jtPays : SR_PAY.slice(0, 3); const tieOk = Math.abs(R.glDiff) < 0.005 && (lf || Math.abs(R.recTotal - R.reportTotal) < 0.005); return (
{/* ── filter bar (sticky) ── */}
{preset === "custom" && <> setCustom({ ...custom, from: e.target.value })} style={srInp} data-sr="from" /> to setCustom({ ...custom, to: e.target.value })} style={srInp} data-sr="to" /> }
{techOpen &&
{R.techOpts.map(([id, name]) => ( ))}
}
{payOpen &&
{SR_PAY.map(p => ( ))}
}
{R.from} → {R.to} · sales use the {basisLbl} of closed & posted ROs · {R.counts.ros} posted RO(s) · report total {srMoney(R.reportTotal)} {lf && · Tech/job/pay filters apply to job lines; RO-level fees & tax show only without them. Reconciliation ties the full ROs.}
{(R.unposted.length > 0 || R.noDate.length > 0) && (
{R.unposted.length > 0 &&
Closed but not posted to the GL — not counted as sales: {R.unposted.map(n => { e.preventDefault(); setRoView(n.id); }} style={{ marginRight: 6 }}>{n.id})}
} {R.noDate.length > 0 &&
Posted ROs with no {basisLbl} — excluded on this basis: {R.noDate.map(n => n.id).join(", ")}
}
)} {/* 1. Technicians */} {R.techList.length === 0 && } {R.techList.map(t => ( ))}
TechClocked hrsClocked sourceBilled hrsBilled sourceProductivityLabor $ELRROsOpen-RO hrs
No technicians match.
{t.name} {t.id} {t.clocked == null ? — : srHrs(t.clocked)} {t.clockSrc} {Array.from(t.lineSrc).join("; ") || "RO job lines"} {t.clocked ? Math.round(t.billed.v / t.clocked * 100) + "%" : n/a} {t.billed.v ? srMoney(t.labor.v / t.billed.v) : "—"}
{/* 2. Job type × pay type */}
{/* 3. Totals */} {[["Labor", R.tot.labor], ["Parts", R.tot.parts], ["Sublet", R.tot.sublet]].map(([l, b]) => )} {[["Shop supplies", R.tot.shop], ["Card fees / surcharges", R.tot.card], ["Other fees", R.tot.other], ["Discounts", R.tot.discount], ["Tax", R.tot.tax]].map(([l, b]) => )}
{l}
{l}{lf ? naCell : }
{lf ? "Total (matching job lines)" : "Total service sales"}
{/* 4. Counts */}
Posted ROs
Average RO $ (posted){srMoney(R.counts.avg)}
Billed hours per RO{R.counts.hoursPerRO.toFixed(2)}
Labor $ per RO{srMoney(R.counts.laborPerRO)}
Effective labor rate (labor ÷ billed hrs){R.tot.hours.v ? srMoney(R.counts.elr) : "—"}
Open ROs (estimates — not sales){R.counts.openCount}
{/* 5. Pay types */} {SR_PAY.map(p => { const P = R.pay[p.k]; return ( ); })}
Pay typeROsHoursLaborPartsSubletFeesTaxTotal
{p.label} {lf ? naCell : } {lf ? naCell : }
Total{R.counts.ros}{srHrs(R.tot.hours.v)}{srMoney(R.tot.labor.v)}{srMoney(R.tot.parts.v)}{srMoney(R.tot.sublet.v)} {lf ? naCell : srMoney(R.tot.shop.v + R.tot.card.v + R.tot.other.v + R.tot.discount.v)}{lf ? naCell : srMoney(R.tot.tax.v)}{srMoney(R.tot.total.v)}
{/* 6. Reconciliation */}
Σ RO totals {srMoney(R.recTotal)} Report total {srMoney(R.reportTotal)}{lf ? " (filtered lines)" : ""} Σ posted RO journal entries {srMoney(R.glTotal)} Difference {srMoney(R.glDiff)}
{Math.abs(R.glDiff) >= 0.005 &&
ROs that differ from the GL: {R.rec.filter(r => Math.abs(r.diff) >= 0.005).map(r => r.id + " (" + srMoney(r.diff) + ")").join(", ")}
} {R.rec.length === 0 && } {R.rec.map(r => ( ))}
ROCustomerPay typeOpenedClosedPostedRO totalGL (posted)DiffJournal entries
No posted ROs in this range.
{ e.preventDefault(); setRoView(r.id); }} style={{ fontFamily: "var(--jb-mono, monospace)", fontWeight: 700 }}>{r.id} {r.n.customer}{r.n.payLabel} {srFmtDay(r.n.dates.open)}{srFmtDay(r.n.dates.close)}{srFmtDay(r.n.dates.post)} {srMoney(r.total)}{Math.abs(r.hdrDiff) >= 0.005 && *} {srMoney(r.gl)} = 0.005 ? "var(--jb-red, #C03546)" : undefined, fontWeight: Math.abs(r.diff) >= 0.005 ? 700 : 400 }}>{srMoney(r.diff)} {r.jes.join(", ") || none found}
Total ({R.rec.length} ROs){srMoney(R.recTotal)}{srMoney(R.glTotal)}{srMoney(R.glDiff)}
{R.rec.some(r => Math.abs(r.hdrDiff) >= 0.005) &&
* RO total is rebuilt from its components; the RO's stored header total differs (hover for the header figure).
}
{/* Open work */} {R.est.length === 0 && } {R.est.map(e => ( ))} {R.est.length > 0 && }
ROCustomerPay typeOpenedTechEst. hoursApproved laborEstimate total
No open ROs.
{ ev.preventDefault(); setRoView(e.id); }} style={{ fontFamily: "var(--jb-mono, monospace)", fontWeight: 700 }}>{e.id} {e.n.customer}{e.n.payLabel}{srFmtDay(e.n.dates.open)}{e.n.techName} {e.hours.toFixed(1)} {e.appr ? srMoney(e.appr.approved) : "—"} {srMoney(e.est)} est.
Estimate total — not a sale{srMoney(R.est.reduce((s, e) => s + e.est, 0))}
{/* drill-down */} {drill && !roView && ( setDrill(null)} buttons={ setDrill(null)}>Close}>
{drill.rows.length} RO(s) · {basisLbl} basis · click an RO for its detail
{drill.rows.map(r => { const n = normBy[r.id]; return ( setRoView(r.id)} data-srdrillrow={r.id}> ); })} {drill.fmt !== "n" && }
ROCustomerStatus{basisLbl}Amount
{r.id} {n ? n.customer : ""}{n ? (n.status === "open" ? "Open (estimate)" : n.posted ? "Posted" : "Closed") : ""} {n ? srFmtDay(n.dates[basis] || (n.status === "open" ? n.dates.open : null)) : ""} {drill.fmt === "h" ? srHrs(r.amt) : drill.fmt === "n" ? "" : srMoney(r.amt)}
Total{drill.fmt === "h" ? srHrs(drill.rows.reduce((s, r) => s + r.amt, 0)) : srMoney(drill.rows.reduce((s, r) => s + r.amt, 0))}
)} {roView && normBy[roView] && (() => { const n = normBy[roView]; const c = n.comp; return ( setRoView(null)} buttons={<> {go && { try { localStorage.setItem("jb_ro_focus", n.id); } catch (e) {} window.jbROFocus = n.id; setRoView(null); setDrill(null); go("service"); }}>Open in RO editor} setRoView(null)}>{drill ? "Back" : "Close"}}>
Status: {n.status === "open" ? "Open — estimate, not a sale" : n.posted ? "Closed & posted" : "Closed, NOT posted"}
Pay type: {n.payLabel} ({n.ro.payType || "—"})
Vehicle: {n.vehicle}
Opened: {srFmtDay(n.dates.open)}
Closed: {srFmtDay(n.dates.close)}
Posted: {srFmtDay(n.dates.post)}
{n.lines.map((l, i) => ( ))}
LineOpTechPayHrsLaborParts
{l.desc}{l.decl ? " (declined)" : ""}{l.plug ? " *" : ""}{l.op}{l.techName}{SR_PAY_LABEL[l.pay]} {srHrs(l.hours)}{srMoney(l.labor)}{srMoney(l.parts)}
{[["Labor", c.labor], ["Parts", c.parts], ["Sublet", c.sublet], ["Shop supplies", c.shop], ["Card fees", c.card], ["Other fees", c.other], ["Discounts", c.discount], ["Tax", c.tax]].filter(([, v], i) => i < 2 || v).map(([l, v]) => )} {n.status !== "open" && }
{l}{srMoney(v)}
{n.status === "open" ? "Estimate total (not a sale)" : "RO total"}{srMoney(c.total)}
GL (posted, sale-side){srMoney(n.glNet)}
{n.jes.length > 0 &&
Journal entries: {n.jes.map(je =>
{je.id} · {je.date} · {je.desc} · {(je.lines || []).map(l => l.acct + (srNum(l.dr) ? " DR " + srR2(l.dr) : " CR " + srR2(l.cr))).join(" | ")}
)}
} {Array.isArray(n.ro.payments) && n.ro.payments.length > 0 &&
Payments: {n.ro.payments.map((p, i) => {p.method || p.type || "payment"} {srMoney(srFirst(p.amount, p.amt))})}
} {n.lines.some(l => l.plug) &&
* Line carries the gap between the RO header and its job lines so totals tie to the RO.
}
); })()}
); }; // Pure report builder exposed for probes / other modules (read-only). const srServiceReport = (filters) => srBuildReport({ preset: "mtd", basis: "close", techs: [], pays: [], jobDim: "op", jobVal: "", ...(filters || {}) }); const srServiceReportCsv = (filters, key) => { const R = srServiceReport(filters); const s = srSections(R).find(x => x.key === key); return s ? srCsv([s.head].concat(s.rows)) : ""; }; const srServiceReportPrintHtml = (filters, key) => srPrintHtml(srServiceReport(filters), key || null); Object.assign(window, { ServiceReports, srServiceReport, srServiceReportCsv, srServiceReportPrintHtml });