/* ============================================================= CoreIQ Office — Reports, LIVE. Profit analysis and the open-items re-order list over the WHOLE sales history (live tills + migrated years, one seamless union), with the rule that every row is one click from its action: a product row resolves its preferred supplier from the store's ranked line-up and "Add to order" builds a draft PO per supplier; the Drafts tray sends through the existing PharmX transmit path. Uses shared bare globals: OIcon, Screen, StatusPill, window.KpiCard, window.useOffice, window.OfficeAPI. ============================================================= */ const { useState: rptUseState, useEffect: rptUseEffect, useCallback: rptUseCallback, useMemo: rptUseMemo } = React; const rptMoney = (c) => "$" + (Number(c || 0) / 100).toLocaleString("en-AU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const rptNum = (v) => Number(v || 0).toLocaleString("en-AU", { maximumFractionDigits: 1 }); const rptDate = (s) => (s ? String(s).slice(0, 10) : "—"); /* Expired reads differently from expiring soon: one comes off the shelf today. */ function rptExpiryTone(iso) { const days = Math.round((Date.parse(iso + "T00:00:00Z") - Date.now()) / 86400000); if (days < 0) return { color: "var(--void-text)", fontWeight: 600 }; if (days <= 60) return { color: "var(--hold-text)", fontWeight: 600 }; return undefined; } const rptIso = (d) => d.toISOString().slice(0, 10); /* Period presets — from/to are inclusive ISO dates. */ function rptPresets() { const now = new Date(); const today = rptIso(now); const monthStart = today.slice(0, 8) + "01"; const d30 = new Date(now); d30.setDate(d30.getDate() - 29); const q = Math.floor(now.getMonth() / 3) * 3; const qStart = rptIso(new Date(Date.UTC(now.getFullYear(), q, 1))); const yStart = now.getFullYear() + "-01-01"; return [ { k: "month", l: "This month", from: monthStart, to: today }, { k: "30d", l: "Last 30 days", from: rptIso(d30), to: today }, { k: "quarter", l: "This quarter", from: qStart, to: today }, { k: "year", l: "This year", from: yStart, to: today }, { k: "all", l: "All history", from: "2000-01-01", to: today }, ]; } /* ---- CSV download (client-side, from the rows on screen) ---------- */ function rptCsv(filename, header, rows) { const esc = (v) => { const s = String(v == null ? "" : v); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; const text = [header, ...rows].map((r) => r.map(esc).join(",")).join("\n"); const url = URL.createObjectURL(new Blob([text], { type: "text/csv" })); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(url), 5000); } /* ---- The per-row supplier + Add-to-order cell --------------------- */ function RptOrderCell({ row, pref, onAdded, pushToast }) { const [qty, setQty] = rptUseState(String(Math.max(1, Math.ceil(row.quantity || 1)))); const [supplierId, setSupplierId] = rptUseState(null); // null = preferred const [busy, setBusy] = rptUseState(false); if (!row.productId) { return not ranged — no product to order; } if (pref === undefined) return ; if (!pref) { return no supplier on file; } const usingCheaper = supplierId !== null; const chosenName = usingCheaper ? pref.cheaperAt.supplierName : pref.supplierName; const chosenPrice = usingCheaper ? pref.cheaperAt.unitCostCents : pref.unitCostCents; function add() { const n = Number(qty); if (!(n > 0)) { pushToast({ kind: "hold", icon: "alert", title: "Quantity needed", meta: "Enter how many to order" }); return; } if (busy) return; setBusy(true); window.OfficeAPI.addOrderDraftLine({ productId: row.productId, quantity: n, supplierId: supplierId || undefined }) .then((r) => { setBusy(false); pushToast({ kind: "paid", icon: "check", title: "Added to " + r.supplierName + " draft", meta: row.label + " × " + n }); onAdded(); }) .catch((e) => { setBusy(false); pushToast({ kind: "hold", icon: "alert", title: "Couldn't add", meta: String((e && e.message) || e) }); }); } return ( {chosenName}{chosenPrice != null ? " · " + rptMoney(chosenPrice) : ""} {pref.cheaperAt && !usingCheaper && ( )} {usingCheaper && ( )} setQty(e.target.value.replace(/[^0-9]/g, ""))} onKeyDown={(e) => { if (e.key === "Enter") add(); }} /> ); } /* ---- Drafts tray — one card per supplier draft PO ----------------- */ function RptDraftsTray({ drafts, onChanged, onClose, pushToast }) { const [busyId, setBusyId] = rptUseState(null); function send(d) { if (busyId) return; setBusyId(d.id); window.OfficeAPI.sendOrderDraft(d.id) .then((r) => { setBusyId(null); pushToast({ kind: "paid", icon: "check", title: "Order transmitted", meta: r.orderNumber + " → " + d.supplierName }); onChanged(); }) .catch((e) => { setBusyId(null); pushToast({ kind: "hold", icon: "alert", title: "Couldn't send", meta: String((e && e.message) || e) }); }); } function discard(d) { if (busyId) return; setBusyId(d.id); window.OfficeAPI.discardOrderDraft(d.id) .then(() => { setBusyId(null); onChanged(); }) .catch((e) => { setBusyId(null); pushToast({ kind: "hold", icon: "alert", title: "Couldn't discard", meta: String((e && e.message) || e) }); }); } function setQty(line, q) { window.OfficeAPI.setOrderDraftLineQty(line.id, q) .then(onChanged) .catch((e) => pushToast({ kind: "hold", icon: "alert", title: "Couldn't update", meta: String((e && e.message) || e) })); } return (
Order drafts
{drafts.length === 0 && (
Nothing drafted yet — use "Add to order" on a report row.
)} {drafts.map((d) => (
{d.supplierName} {d.lineCount} lines · {rptMoney(d.totalCents)}
{d.lines.map((l) => (
{l.name} { const q = Number(e.target.value); if (Number.isFinite(q) && q !== l.quantity && q >= 0) setQty(l, q); }} /> {rptMoney(l.lineTotalCents)}
))}
{d.pharmxReady ? ( ) : ( no electronic channel )}
))}
); } /* ---- The Reports screen ------------------------------------------ */ function ReportsLive() { const { pushToast } = window.useOffice(); const presets = rptUseMemo(rptPresets, []); const [tab, setTab] = rptUseState("profit"); const [preset, setPreset] = rptUseState("month"); const [from, setFrom] = rptUseState(presets[0].from); const [to, setTo] = rptUseState(presets[0].to); const [groupBy, setGroupBy] = rptUseState("product"); const [stockFilter, setStockFilter] = rptUseState("all"); // all | out (SOH <= 0) const [data, setData] = rptUseState(null); // { rows, totals } const [openItems, setOpenItems] = rptUseState(null); const [prefs, setPrefs] = rptUseState({}); const [drafts, setDrafts] = rptUseState([]); const [trayOpen, setTrayOpen] = rptUseState(false); const [err, setErr] = rptUseState(null); const [loading, setLoading] = rptUseState(false); // Range report — one brand / one supplier's whole range (self-serve version // of the hand-built MooGoo / Bare Medical / Herbs of Gold reports). const [brands, setBrands] = rptUseState([]); const [rangeSuppliers, setRangeSuppliers] = rptUseState([]); const [rangeBrand, setRangeBrand] = rptUseState(""); const [rangeSupplier, setRangeSupplier] = rptUseState(""); const [rangeCategory, setRangeCategory] = rptUseState(""); const [rangeCategories, setRangeCategories] = rptUseState([]); const rptRangeLabel = () => rangeBrand || (rangeSuppliers.find((x) => x.id === rangeSupplier) || {}).name || (rangeCategory === "(uncategorised)" ? "Uncategorised" : (rangeCategories.find((c) => c.id === rangeCategory) || {}).name) || ""; const [range, setRange] = rptUseState(null); const [rangeLoading, setRangeLoading] = rptUseState(false); const loadDrafts = rptUseCallback(() => { window.OfficeAPI.listOrderDrafts().then(setDrafts).catch(() => {}); }, []); const load = rptUseCallback(() => { setLoading(true); setErr(null); const q = { from, to }; const profit = window.OfficeAPI.reportProfit(Object.assign({ groupBy }, q)) .then((d) => { setData(d); const ids = d.rows.filter((r) => r.productId).slice(0, 400).map((r) => r.productId); return ids.length ? window.OfficeAPI.preferredSuppliers(ids).then(setPrefs) : setPrefs({}); }); const open = window.OfficeAPI.reportOpenItems(q).then(setOpenItems); Promise.all([profit, open]) .then(() => setLoading(false)) .catch((e) => { setLoading(false); setErr(String((e && e.message) || e)); }); }, [from, to, groupBy]); rptUseEffect(() => { load(); }, [load]); rptUseEffect(() => { loadDrafts(); }, [loadDrafts]); rptUseEffect(() => { window.OfficeAPI.reportBrands().then(setBrands).catch(() => {}); window.OfficeAPI.listSuppliers().then((ss) => setRangeSuppliers(ss || [])).catch(() => {}); window.OfficeAPI.listCategories().then((cs) => setRangeCategories(cs || [])).catch(() => {}); }, []); rptUseEffect(() => { const scope = rangeBrand ? { brand: rangeBrand } : rangeSupplier ? { supplierId: rangeSupplier } : rangeCategory ? { categoryId: rangeCategory } : null; if (!scope) { setRange(null); return; } // The sales columns follow the period chosen at the top of the screen; // stock and expiry are always "now" and the header says so. const q = Object.assign({ from, to }, scope); setRangeLoading(true); window.OfficeAPI.reportRange(q) .then((rep) => { setRange(rep); setRangeLoading(false); }) .catch((e) => { setRangeLoading(false); setErr(String((e && e.message) || e)); }); }, [rangeBrand, rangeSupplier, rangeCategory, from, to]); function pickPreset(p) { setPreset(p.k); setFrom(p.from); setTo(p.to); } function exportCsv() { if (tab === "range" && range) { const label = rptRangeLabel() || "range"; const money = (c) => (c == null ? "" : (c / 100).toFixed(2)); const rows = range.rows.map((r) => [ r.name, r.barcode || "MISSING", r.sohUnits, r.unitsSold, r.nextExpiry || "", money(r.sellPriceCents), money(r.costCents), r.gpPercent == null ? "" : Math.round(r.gpPercent) + "%", ]); rows.push([""], ["Totals", "", range.totals.sohUnits, range.totals.unitsSold, "", "", "", ""]); rows.push(["Value at cost (ex GST)", "", "", "", "", "", money(range.totals.valueAtCostCents), ""]); rows.push(["Value at retail (inc GST)", "", "", "", "", money(range.totals.valueAtRetailCents), "", ""]); rptCsv( label + " Inventory.csv", ["Product", "Barcode", "Stock On Hand", "Units Sold", "Next Expiry", "Sell Price (inc GST)", "Invoice Cost (ex GST)", "GP %"], rows, ); return; } if (tab === "profit" && data) { const periodExport = groupBy === "day" || groupBy === "week" || groupBy === "month"; rptCsv( (periodExport ? "trading-by-" + groupBy + " " : "profit-analysis ") + from + " to " + to + ".csv", periodExport ? [groupBy === "day" ? "Day" : groupBy === "week" ? "Week" : "Month", "Qty", "Gross sales", "Net sales", "Cost", "Gross profit", "GP %", "Sales", "Avg basket"] : ["Group", "Qty", "Gross sales", "Net sales", "Cost", "Gross profit", "GP %", "SOH"], data.rows.map((r) => { const common = [ r.label, r.quantity, (r.grossCents / 100).toFixed(2), (r.netCents / 100).toFixed(2), r.costCents == null ? "" : (r.costCents / 100).toFixed(2), r.gpCents == null ? "" : (r.gpCents / 100).toFixed(2), r.gpPercent == null ? "" : r.gpPercent, ]; return periodExport ? common.concat([r.salesCount, r.averageBasketCents == null ? "" : (r.averageBasketCents / 100).toFixed(2)]) : common.concat([r.sohUnits == null ? "" : r.sohUnits]); }), ); } else if (openItems) { rptCsv( "open-items " + from + " to " + to + ".csv", ["Note", "Times sold", "Units", "Value", "First sold", "Last sold"], openItems.map((r) => [r.note, r.timesSold, r.units, (r.valueCents / 100).toFixed(2), rptDate(r.firstAt), rptDate(r.lastAt)]), ); } } const t = (data && data.totals) || {}; const openCount = openItems ? openItems.length : 0; // A trading period has no shelf and nothing to reorder, so those two columns // carry sales count and average basket instead of showing an empty SOH and a // reorder control that would make no sense against a Tuesday. const rptIsPeriod = groupBy === "day" || groupBy === "week" || groupBy === "month"; const rptGroupNoun = rptIsPeriod ? (groupBy === "day" ? "day" : groupBy === "week" ? "week" : "month") : groupBy === "product" ? "product" : groupBy === "department" ? "department" : "clerk"; const profitCols = rptIsPeriod ? "minmax(200px, 2fr) 60px 100px 100px 100px 100px 60px 70px 110px" : "minmax(200px, 2fr) 60px 100px 100px 100px 100px 60px 70px minmax(280px, 2.2fr)"; const openCols = "minmax(240px, 2.4fr) 90px 70px 100px 110px 110px"; const rangeCols = "minmax(240px, 2.6fr) 150px 60px 60px 110px 110px 70px 110px"; return ( } > {/* Period + grouping bar */} {tab !== "range" && (
{presets.map((p) => ( ))} { setPreset("custom"); setFrom(e.target.value); }}/> – { setPreset("custom"); setTo(e.target.value); }}/> {tab === "profit" && groupBy === "product" && ( )} {tab === "profit" && ( )}
)} {err &&
Couldn't load: {err}
} {tab === "profit" && (
Profit by {rptGroupNoun} {data && {data.rows.length}}
{loading && !data &&
Loading…
} {data && data.rows.length === 0 && (
No sales in this period.
)} {data && data.rows.length > 0 && (() => { const shown = groupBy === "product" && stockFilter === "out" ? data.rows.filter((r) => r.sohUnits != null && r.sohUnits <= 0) : data.rows; if (shown.length === 0) { return
Nothing sold in the period is out of stock — the shelf covers everything that moved.
; } return (
{rptGroupNoun.charAt(0).toUpperCase() + rptGroupNoun.slice(1)} Qty Gross Net Cost GP GP % {rptIsPeriod ? ( Sales ) : ( SOH )} {rptIsPeriod ? ( Avg basket ) : ( {groupBy === "product" ? "Reorder" : ""} )}
{shown.map((r) => (
{r.isOpenItem ? {r.label} : r.label} {rptNum(r.quantity)} {rptMoney(r.grossCents)} {rptMoney(r.netCents)} {r.costCents == null ? "—" : rptMoney(r.costCents)} {r.gpCents == null ? "—" : rptMoney(r.gpCents)} {r.gpPercent == null ? "—" : rptNum(r.gpPercent) + "%"} {rptIsPeriod ? ( {rptNum(r.salesCount)} ) : ( {r.sohUnits == null ? "—" : rptNum(r.sohUnits)} )} {rptIsPeriod ? ( {r.averageBasketCents == null ? "—" : rptMoney(r.averageBasketCents)} ) : ( {groupBy === "product" && ( )} )}
))}
); })()}
)} {tab === "openItems" && (
Open items — sold off-catalogue, by the note typed at the till {openItems && {openItems.length}}
These sales moved no stock — the note is their only trace. Range the product (add it with its barcode), then it appears here with a Reorder button like any other line.
{openItems && openItems.length === 0 && (
No open items in this period — everything scanned.
)} {openItems && openItems.length > 0 && (
Note Times sold Units Value First sold Last sold
{openItems.map((r, i) => (
{r.note || "(no description typed)"} {rptNum(r.timesSold)} {rptNum(r.units)} {rptMoney(r.valueCents)} {rptDate(r.firstAt)} {rptDate(r.lastAt)}
))}
)}
)} {tab === "range" && (
Brand {/* The three are mutually exclusive: picking one clears the others, so the table always says which single thing it is showing. */} or Supplier or Category {rangeLoading && }
{!range && !rangeLoading && (
Pick a brand, a supplier or a category — you get the whole range in one table: stock on hand, sales, sell price, latest invoiced cost, margin and barcode status. Export CSV gives the shareable file. A brand appears in the list once its products carry the brand field; category covers everything else, including the products that have no category set at all.
)} {range && (
Range — {rptRangeLabel()} Sold covers {from} to {to} · stock and expiry are as at now {range.rows.length}
{range.rows.length === 0 && (
No products found for this selection.
)} {range.rows.length > 0 && (
Product Barcode SOH Sold Sell (inc GST) Cost (ex GST) GP % Expiry
{range.rows.map((r) => (
{r.name} {r.barcode || none} {rptNum(r.sohUnits)} {rptNum(r.unitsSold)} {rptMoney(r.sellPriceCents)} {r.costCents == null ? "—" : rptMoney(r.costCents)} {r.gpPercent == null ? "—" : Math.round(r.gpPercent) + "%"} 1 ? r.expiryFlags + " sightings open, soonest shown" : "Sighted on the shop floor") : "Nobody has flagged this product"}> {r.nextExpiry ? {r.nextExpiry} : }
))}
)}
)}
)} {trayOpen && ( setTrayOpen(false)} pushToast={pushToast} /> )}
); } window.OFFICE_SCREENS = Object.assign(window.OFFICE_SCREENS || {}, { reports: ReportsLive, });