/* =============================================================
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 && (
setSupplierId(pref.cheaperAt.supplierId)}
>
{pref.cheaperAt.supplierName} {rptMoney(pref.cheaperAt.unitCostCents)}
)}
{usingCheaper && (
setSupplierId(null)}>
back to {pref.supplierName}
)}
setQty(e.target.value.replace(/[^0-9]/g, ""))}
onKeyDown={(e) => { if (e.key === "Enter") add(); }}
/>
{busy ? "Adding…" : "Add to order"}
);
}
/* ---- 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
Close
{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)}
setQty(l, 0)}>×
))}
discard(d)}>Discard
{d.pharmxReady ? (
send(d)}>
{busyId === d.id ? "Sending…" : "Send order"}
) : (
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 (
Export CSV
{ loadDrafts(); setTrayOpen(true); }}>
Drafts{drafts.length ? " (" + drafts.length + ")" : ""}
Refresh
}
>
{/* Period + grouping bar */}
{tab !== "range" && (
{presets.map((p) => (
pickPreset(p)}>
{p.l}
))}
{ setPreset("custom"); setFrom(e.target.value); }}/>
–
{ setPreset("custom"); setTo(e.target.value); }}/>
{tab === "profit" && groupBy === "product" && (
setStockFilter("all")}>All stock
setStockFilter("out")}>Out of stock
)}
{tab === "profit" && (
setGroupBy(e.target.value)}>
By product
By department
By clerk
By day
By week
By month
)}
)}
{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. */}
{ setRangeBrand(e.target.value); if (e.target.value) { setRangeSupplier(""); setRangeCategory(""); } }}>
Choose a brand…
{brands.map((b) => {b} )}
or
Supplier
{ setRangeSupplier(e.target.value); if (e.target.value) { setRangeBrand(""); setRangeCategory(""); } }}>
Choose a supplier…
{rangeSuppliers.map((sp) => {sp.name} )}
or
Category
{ setRangeCategory(e.target.value); if (e.target.value) { setRangeBrand(""); setRangeSupplier(""); } }}>
Choose a category…
{/* Sorted by NAME here, not by the sort_order the API returns.
That order is the store's own merchandising sequence and is
right for the till and the product form — but this is a
find-it list of ~40 departments, and you scan those
alphabetically. Sorted on a copy so nothing else shifts. */}
{rangeCategories
.slice()
.sort((a, b) => String(a.name).localeCompare(String(b.name), "en-AU", { sensitivity: "base", numeric: true }))
.map((c) => {c.name} )}
{/* Findable on purpose — these are the products someone needs to fix. */}
(uncategorised)
{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,
});