Files
threadcount-community/app/app/report/page.tsx
T
ThreadCount 344b1701dd ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
2026-09-13 08:54:35 +10:00

538 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { PageHead, Dialog, Empty, KpiStrip, th } from "@/components/ui";
import { ccOf, countsAsIssued, csvEsc, csvOf, fmtDate, fyStart, issueCost, label, longLabel, money, monthLabel, onhand, orderTotal, prevMonth, setsCap, shiftMonth, signedInt, signedMoney, staffName } from "@/lib/compute";
import { downloadCsv, printDoc, tbl, type Col } from "@/lib/print";
const TABS = ["Overview", "Journal", "Top stock", "Valuation", "Shrinkage", "Exceptions", "Suppliers", "Approvals", "Pre-loved"] as const;
type Tab = (typeof TABS)[number];
/* Every table and every list on this screen sits in the same bordered block with its name across
the top, because nine tabs that each invent their own heading is how a month-end pack ends up
looking like nine different reports. `flag` is for the tab that is telling finance something is
wrong — an unpostable journal line, stock gone missing, a garment handed over past the ceiling —
and marks it the way the rest of the app marks trouble: a rule down the edge and a mark beside
the name, so it does not rely on a red that is also the brand's. */
function Panel({ title, aside, right, flag, children }: { title: React.ReactNode; aside?: React.ReactNode; right?: React.ReactNode; flag?: boolean; children: React.ReactNode }) {
return (
<div className={"tc-panel" + (flag ? " tc-flag" : "")} style={{ marginTop: "var(--space-6)" }}>
<div className="tc-panel-head">
<span>{flag && <span className="tc-mark" aria-hidden="true" />}{title}</span>
{(aside || right) && <span style={{ display: "flex", alignItems: "baseline", gap: "var(--space-3)" }}>{aside && <span className="tc-panel-aside">{aside}</span>}{right}</span>}
</div>
{children}
</div>
);
}
export default function ReportPage() {
const { s } = useSnap();
const { L, byId, staffById } = useDerived();
const [month, setMonth] = useState(s.today.slice(0, 7));
const [tab, setTab] = useState<Tab>("Overview");
// The cost centre whose issues are open, as the keys its figure was summed from. Only the keys
// are held: the items and the money are recounted from the snapshot on every render, so the
// drill-down still agrees with the row underneath it if stock moves while the dialog is open.
const [drill, setDrill] = useState<{ cc: string; dept: string; keys: string[] } | null>(null);
const R = useMemo(() => {
const months = new Set([s.today.slice(0, 7)]);
s.issues.forEach((i) => months.add(i.date.slice(0, 7))); s.orders.forEach((o) => o.date && months.add(o.date.slice(0, 7)));
for (let i = 5; i >= 0; i--) months.add(shiftMonth(month, -i)); // trend bars are clickable, so they must be selectable
const repMonths = [...months].sort().reverse();
const cost = (itemId: string) => byId[itemId]?.cost || 0; // catalogue cost (stocktake lines, valuation)
const mIssues = s.issues.filter((i) => i.date.slice(0, 7) === month && countsAsIssued(i) && !i.preloved);
const mPl = s.issues.filter((i) => i.date.slice(0, 7) === month && countsAsIssued(i) && i.preloved);
const pm = prevMonth(month);
const pIssues = s.issues.filter((i) => i.date.slice(0, 7) === pm && countsAsIssued(i) && !i.preloved);
// Pre-loved: free reissues (value saved at catalogue cost), hand-ins, and the pool at $0.
const plIssueRows = mPl.map((i) => { const it = byId[i.itemId]; return { date: i.date, who: staffName(staffById[i.staffId], "—"), item: label(it), size: it ? String(it.sizes[i.si]) : "?", qty: i.qty, saved: i.qty * (it?.cost || 0) }; });
const plSaved = plIssueRows.reduce((t, r) => t + r.saved, 0), plQty = mPl.reduce((t, i) => t + i.qty, 0);
const mHi = s.handins.filter((h) => h.date.slice(0, 7) === month);
const hiRows = mHi.map((h) => ({ date: h.date, who: staffName(staffById[h.staffId], "—"), by: h.by, good: h.lines.filter((l) => l.cond === "Good").reduce((t, l) => t + l.qty, 0), rag: h.lines.filter((l) => l.cond === "Rag").reduce((t, l) => t + l.qty, 0), credit: h.credit ? "Credited" : "—" }));
const ragMonth = hiRows.reduce((t, r) => t + r.rag, 0);
const plByItem: Record<string, string[]> = {};
for (const k in s.stock) { const n = s.stock[k].preloved; if (!(n > 0)) continue; const itemId = k.slice(0, k.lastIndexOf(":")), si = +k.slice(k.lastIndexOf(":") + 1); const it = byId[itemId]; if (!it) continue; (plByItem[itemId] = plByItem[itemId] || []).push(`${it.sizes[si]} ×${n}`); }
const plPoolRows = Object.keys(plByItem).map((itemId) => ({ item: label(byId[itemId]), sizes: plByItem[itemId].join(", "), total: plByItem[itemId].reduce((t, x) => t + parseInt(x.split("×")[1], 10), 0) }));
const plPoolTotal = plPoolRows.reduce((t, r) => t + r.total, 0);
type Agg = { items: number; amt: number };
const sumBy = (arr: typeof mIssues, keyFn: (i: (typeof arr)[number]) => string) => { const m: Record<string, Agg> = {}; for (const i of arr) { const k = keyFn(i); if (!m[k]) m[k] = { items: 0, amt: 0 }; m[k].items += i.qty; m[k].amt += i.qty * issueCost(i, byId); } return m; };
const ccKey = (i: (typeof mIssues)[number]) => { const st = staffById[i.staffId]; return st ? (ccOf(s, st) || "—") + "|" + (st.dept || "Unknown") : "—|Unknown"; };
const byCC = sumBy(mIssues, ccKey), byCCPrev = sumBy(pIssues, ccKey);
// Union of this month's and last month's cost centres so the Prev column reconciles to the previous-month total.
const ccKeys = [...new Set([...Object.keys(byCC), ...Object.keys(byCCPrev)])];
const ccRows = ccKeys.map((k) => { const v = byCC[k] || { items: 0, amt: 0 }; const [cc, dept] = k.split("|"); const prev = byCCPrev[k]?.amt || 0; return { key: k, cc, dept, items: v.items, amt: v.amt, prev, delta: v.amt - prev }; }).sort((a, b) => b.amt - a.amt || b.prev - a.prev);
// What each cost-centre figure is actually made of, filed under the same key the total was
// grouped on. Grouping the detail the same way as the total is what stops a drill-down from
// disagreeing with the row that opened it — a ward manager checking their number would rather
// have no drill-down than one that doesn't add up.
const ccLines: Record<string, { date: string; who: string; item: string; size: string; qty: number; unit: number; amt: number }[]> = {};
for (const i of mIssues) { const k = ccKey(i); const it = byId[i.itemId]; const unit = issueCost(i, byId); (ccLines[k] = ccLines[k] || []).push({ date: i.date, who: staffName(staffById[i.staffId], "—"), item: label(it), size: it ? String(it.sizes[i.si]) : "?", qty: i.qty, unit, amt: i.qty * unit }); }
const totAmt = mIssues.reduce((t, i) => t + i.qty * issueCost(i, byId), 0), totPrev = pIssues.reduce((t, i) => t + i.qty * issueCost(i, byId), 0), totItems = mIssues.reduce((t, i) => t + i.qty, 0);
// "Placed" = sent to the supplier; drafts (incl. auto-replenishment) are not spend yet.
// Back orders carry the parent's short lines, so they're excluded from spend to avoid counting those lines twice.
const placed = (o: (typeof s.orders)[number]) => o.status !== "Cancelled" && o.status !== "Draft" && !o.parentId;
const mOrders = s.orders.filter((o) => o.date.slice(0, 7) === month && placed(o));
const ordSpend = mOrders.reduce((t, o) => t + orderTotal(o, byId), 0);
const byG = sumBy(mIssues, (i) => staffById[i.staffId]?.group || "Unknown");
const groupRows = Object.entries(byG).sort((a, b) => b[1].amt - a[1].amt).map(([g, v]) => ({ g, ...v }));
const supAgg: Record<string, { n: number; amt: number; inv: string[] }> = {};
for (const o of mOrders) { const v = orderTotal(o, byId); if (!supAgg[o.supplier]) supAgg[o.supplier] = { n: 0, amt: 0, inv: [] }; supAgg[o.supplier].n++; supAgg[o.supplier].amt += v; if (o.invoice && !supAgg[o.supplier].inv.includes(o.invoice)) supAgg[o.supplier].inv.push(o.invoice); for (const rc of o.receipts) if (rc.invoice && !supAgg[o.supplier].inv.includes(rc.invoice)) supAgg[o.supplier].inv.push(rc.invoice); }
const supRows = Object.entries(supAgg).sort((a, b) => b[1].amt - a[1].amt).map(([name, v]) => ({ name, n: v.n, amt: v.amt, invoices: v.inv.join(", ") || "—" }));
const issueAgg = (m: string) => { const a = s.issues.filter((i) => i.date.slice(0, 7) === m && countsAsIssued(i) && !i.preloved); return { items: a.reduce((t, i) => t + i.qty, 0), amt: a.reduce((t, i) => t + i.qty * issueCost(i, byId), 0) }; };
const orderAgg = (m: string) => s.orders.filter((o) => o.date.slice(0, 7) === m && placed(o)).reduce((t, o) => t + orderTotal(o, byId), 0);
const fyMonths: string[] = []; { let cur = fyStart(month + "-15").slice(0, 7); let g = 0; while (cur <= month && g++ < 13) { fyMonths.push(cur); cur = shiftMonth(cur, 1); } }
let fti = 0, fta = 0, fto = 0;
const fyRows = fyMonths.map((m) => { const ia = issueAgg(m); const ov = orderAgg(m); fti += ia.items; fta += ia.amt; fto += ov; return { m, label: monthLabel(m, { month: "short", year: "2-digit" }), items: ia.items, issued: ia.amt, orders: ov }; });
const trendM: string[] = []; for (let i = 5; i >= 0; i--) trendM.push(shiftMonth(month, -i));
const tv = trendM.map((m) => issueAgg(m).amt); const tmax = Math.max(...tv, 1);
const trend = trendM.map((m, i) => ({ m, label: monthLabel(m, { month: "short" }), amt: tv[i], h: tv[i] ? Math.max(Math.round((tv[i] / tmax) * 70), 4) : 2, sel: m === month }));
const byS = sumBy(mIssues, (i) => i.staffId);
const staffRows = Object.entries(byS).sort((a, b) => b[1].amt - a[1].amt).map(([sid, v]) => { const st = staffById[sid]; return { who: staffName(st, "—"), cc: ccOf(s, st), ...v }; });
// Journal
const glAcct = s.settings.glAccount || "—";
const jnDesc = `${s.settings.journalDesc || "Uniform issues"} ${monthLabel(month)}`;
// One debit per cost centre: departments that share a CC (or a ccOverride pointing at another dept's code) fold together.
const jnAgg: Record<string, { cc: string; depts: string[]; keys: string[]; items: number; debit: number }> = {};
for (const r of ccRows) { if (r.items <= 0) continue; const cc = r.cc === "—" ? "UNALLOCATED" : r.cc; const a = jnAgg[cc] || (jnAgg[cc] = { cc, depts: [], keys: [], items: 0, debit: 0 }); if (!a.depts.includes(r.dept)) a.depts.push(r.dept); a.keys.push(r.key); a.items += r.items; a.debit += r.amt; }
const jnRows = Object.values(jnAgg).sort((a, b) => b.debit - a.debit).map((a) => ({ cc: a.cc, dept: a.depts.join(" / "), keys: a.keys, gl: glAcct, desc: jnDesc, items: a.items, debit: a.debit }));
const jnUnallocated = jnRows.some((r) => r.cc === "UNALLOCATED");
// Top stock
const byItem: Record<string, Agg> = {}; const fyByItem: Record<string, number> = {};
const fy = fyStart(month + "-15"); // financial year of the selected month
// Every FY figure on this page — Top stock's, Exceptions' and Shrinkage's — stops at the end of
// the selected month. Without the upper bound, reprinting June's pack in September counts three
// months that hadn't happened when June closed, so the reprint no longer agrees with the pack
// finance was already given.
const fyCutoff = shiftMonth(month, 1) + "-01"; // exclusive: dates in `month` sort before it
for (const i of mIssues) { if (!byItem[i.itemId]) byItem[i.itemId] = { items: 0, amt: 0 }; byItem[i.itemId].items += i.qty; byItem[i.itemId].amt += i.qty * issueCost(i, byId); }
for (const i of s.issues) if (countsAsIssued(i) && !i.preloved && i.date >= fy && i.date < fyCutoff) fyByItem[i.itemId] = (fyByItem[i.itemId] || 0) + i.qty;
const mTotQty = Object.values(byItem).reduce((t, v) => t + v.items, 0);
const topRows = Object.entries(byItem).sort((a, b) => b[1].items - a[1].items).slice(0, 15).map(([id, v], n) => ({ n: n + 1, item: label(byId[id]), supplier: byId[id]?.supplier || "—", qty: v.items, val: v.amt, share: Math.round((v.items / Math.max(mTotQty, 1)) * 100) + "%", fyQty: fyByItem[id] || 0 }));
// Valuation
let negSizes = 0;
const valRows = s.catalog.map((it) => { const units = it.sizes.reduce((t, _sz, si) => { const oh = onhand(s, L, `${it.id}:${si}`); if (oh < 0) negSizes++; return t + Math.max(0, oh); }, 0); return { item: longLabel(it), sku: it.sku || "—", supplier: it.supplier || "—", units, cost: it.cost, val: units * it.cost }; }).filter((x) => x.units > 0).sort((a, b) => b.val - a.val);
const valTotUnits = valRows.reduce((t, x) => t + x.units, 0), valTot = valRows.reduce((t, x) => t + x.val, 0);
// Shrinkage
// Bounded at fyCutoff like the other FY figures: a count filed in July must not change the
// shrinkage figure on June's pack after finance has it. Pool counts are at $0, not shrinkage.
const fyTakes = s.stocktakes.filter((h) => h.date >= fy && h.date < fyCutoff && h.mode !== "preloved");
let shU = 0, shV = 0;
const shRows = fyTakes.map((h) => { const nu = h.lines.reduce((t, l) => t + (l.counted - l.sys), 0); const nv = h.lines.reduce((t, l) => t + (l.counted - l.sys) * cost(l.itemId), 0); shU += nu; shV += nv; return { date: h.date, by: h.by, counted: h.counted, variances: h.variances, net: nu, netVal: nv }; });
// Exceptions
const excThreshold = s.settings.exceptionHigh || 10;
const mByStaff: Record<string, number> = {}; for (const i of mIssues) mByStaff[i.staffId] = (mByStaff[i.staffId] || 0) + i.qty;
const cap = setsCap(s.settings.capSets);
// Garments handed over this month past the six sets one person holds, on a coordinator's
// override. That is the one exception the ceiling itself produces, and the counter stamps it on
// the issue for this tab to find. It is read from that stamp rather than from anybody's locker
// today, because today's locker is not June's: a June pack reprinted in September would name
// whoever happens to be past the ceiling now, and saying who that is belongs to the staff
// register and the dashboard. Every stamped row in the month counts, pre-loved and since-returned
// included, because the decision was made at the counter on the day. A partial hand-in splits a
// row without changing its date, so the halves still add up to what went over.
const ovByStaff: Record<string, number> = {};
for (const i of s.issues) if (i.override && i.date.slice(0, 7) === month) ovByStaff[i.staffId] = (ovByStaff[i.staffId] || 0) + i.qty;
// Garments handed over this month outside the person's staff group, on the same tick but stamped
// apart (offGroup), so they are counted and named apart. Same month rule as above. A garment can
// be both, and then it is on both lines, because two rules were bent.
const ogByStaff: Record<string, Record<string, number>> = {};
for (const i of s.issues) if (i.offGroup && i.date.slice(0, 7) === month) { const m = (ogByStaff[i.staffId] = ogByStaff[i.staffId] || {}); const n = label(byId[i.itemId]); m[n] = (m[n] || 0) + i.qty; }
// Garments handed over this month in a cut the person isn't offered, on the same tick and stamped
// apart again (offStyle). Same month rule, and the same reason for counting it apart: the ceiling,
// the staff group and the cut are three different decisions a coordinator made, and a row that
// named them all as "an override" tells whoever reads the pack nothing about which was bent.
const osByStaff: Record<string, Record<string, number>> = {};
for (const i of s.issues) if (i.offStyle && i.date.slice(0, 7) === month) { const m = (osByStaff[i.staffId] = osByStaff[i.staffId] || {}); const n = label(byId[i.itemId]); m[n] = (m[n] || 0) + i.qty; }
// What each person has drawn this financial year, to the end of the selected month. It is a
// tally printed beside the month's figure, and nobody is flagged on it: what anybody may have is
// six sets held at any time, with no year in it, and a report calling somebody over on a yearly
// count sends a coordinator after a new starter the counter has kitted out quite properly.
// Counted here rather than with entUsed(), which always measures the year containing today, so a
// closed month reprints with the figures it was first printed with. fyCutoff is the one Top
// stock and Shrinkage count to, so no two tabs quote a different window for the same month. Same
// rules as entUsed(): pre-loved is free and not counted, a garment returned in good condition
// never counted, and a credited hand-in takes the good garments back off.
const fyByStaff: Record<string, number> = {};
for (const i of s.issues) if (!i.preloved && countsAsIssued(i) && i.date >= fy && i.date < fyCutoff) fyByStaff[i.staffId] = (fyByStaff[i.staffId] || 0) + i.qty;
for (const h of s.handins) if (h.credit && h.date >= fy && h.date < fyCutoff) for (const l of h.lines) fyByStaff[h.staffId] = (fyByStaff[h.staffId] || 0) - l.credited;
const excRows: { who: string; group: string; cc: string; mQty: number; fyQty: number; ovQty: number; ogQty: number; osQty: number; flags: string[]; flag: string }[] = [];
for (const st of s.staff) {
const fyQ = Math.max(0, fyByStaff[st.id] || 0); const mQ = mByStaff[st.id] || 0; const ov = ovByStaff[st.id] || 0;
const og = Object.entries(ogByStaff[st.id] || {}); const ogQ = og.reduce((t, [, n]) => t + n, 0);
const os = Object.entries(osByStaff[st.id] || {}); const osQ = os.reduce((t, [, n]) => t + n, 0);
const flags: string[] = [];
if (ov) flags.push(`Past ${cap} sets on an override — ${ov} garment${ov === 1 ? "" : "s"}`);
if (ogQ) flags.push(`Outside their staff group on an override — ${og.map(([n, q]) => `${n} ×${q}`).join(", ")}`);
if (osQ) flags.push(`Not their uniform style on an override — ${os.map(([n, q]) => `${n} ×${q}`).join(", ")}`);
if (mQ >= excThreshold) flags.push(`${mQ} items this month (threshold ${excThreshold})`);
if (flags.length) excRows.push({ who: staffName(st), group: st.group, cc: ccOf(s, st), mQty: mQ, fyQty: fyQ, ovQty: ov, ogQty: ogQ, osQty: osQ, flags, flag: flags.join(" · ") });
}
// Overrides first, of any kind. Each one is a decision somebody made at the counter, and it is
// the row a coordinator gets asked about. Volume on its own comes after, busiest first.
excRows.sort((a, b) => Number(b.ovQty + b.ogQty + b.osQty > 0) - Number(a.ovQty + a.ogQty + a.osQty > 0) || b.mQty - a.mQty);
// Approvals
const apprRows = s.approvals.filter((a) => a.sets - a.used > 0).map((a) => { const st = staffById[a.staffId]; return { who: staffName(st, "—"), dept: st?.dept || "—", by: a.by, date: a.date, sets: a.sets, used: a.used, rem: a.sets - a.used }; });
const apprTot = apprRows.reduce((t, a) => t + a.rem, 0);
return { repMonths, ccRows, ccLines, totAmt, totPrev, totItems, ordSpend, groupRows, supRows, fyRows, fyTot: { items: fti, issued: fta, orders: fto }, trend, staffRows, glAcct, jnDesc, jnRows, jnUnallocated, topRows, valRows, valTotUnits, valTot, negSizes, shRows, shU, shV, cap, excThreshold, excRows, apprRows, apprTot, plIssueRows, plSaved, plQty, hiRows, ragMonth, plPoolRows, plPoolTotal };
}, [s, L, byId, staffById, month]);
const mLbl = monthLabel(month);
const meta = `${s.settings.facility} · ${s.settings.location} · prepared ${fmtDate(s.today)}${s.settings.coordinator ? " by " + s.settings.coordinator : ""}`;
const jnTotItems = R.jnRows.reduce((t, r) => t + r.items, 0), jnTot = R.jnRows.reduce((t, r) => t + r.debit, 0);
const drillRows = useMemo(() => (drill ? drill.keys.flatMap((k) => R.ccLines[k] || []).sort((a, b) => a.date.localeCompare(b.date) || a.who.localeCompare(b.who) || a.item.localeCompare(b.item)) : []), [drill, R]);
const drillQty = drillRows.reduce((t, r) => t + r.qty, 0), drillAmt = drillRows.reduce((t, r) => t + r.amt, 0);
const csvDrill = () => drill && downloadCsv(`threadcount-cost-centre-${drill.cc.replace(/[^A-Za-z0-9]+/g, "-").toLowerCase()}-${month}.csv`, `Issues behind cost centre,${csvEsc(drill.cc)},${month}\n\n` + csvOf(["Date", "Staff", "Item", "Size", "Qty", "Unit cost", "Value"], [...drillRows.map((r) => [r.date, r.who, r.item, r.size, r.qty, r.unit.toFixed(2), r.amt.toFixed(2)] as (string | number)[]), ["TOTAL", "", "", "", drillQty, "", drillAmt.toFixed(2)]]));
const csvOverview = () => {
let csv = `ThreadCount monthly report,${month},${csvEsc(s.settings.facility)}\n\n` + csvOf(["Cost Centre", "Department", "Items", "Amount", "Previous Month"], [...R.ccRows.map((r) => [r.cc, r.dept, r.items, r.amt.toFixed(2), r.prev.toFixed(2)] as (string | number)[]), ["TOTAL", "", R.totItems, R.totAmt.toFixed(2), R.totPrev.toFixed(2)]]);
csv += "\n" + csvOf(["Staff Group", "Items", "Amount"], R.groupRows.map((g) => [g.g, g.items, g.amt.toFixed(2)]));
csv += "\n" + csvOf(["Staff", "Cost Centre", "Items", "Amount"], R.staffRows.map((r) => [r.who, r.cc, r.items, r.amt.toFixed(2)]));
csv += "\n" + csvOf(["Supplier", "Orders", "Amount"], R.supRows.map((r) => [r.name, r.n, r.amt.toFixed(2)]));
csv += "\n" + csvOf(["FY Month", "Items Issued", "Issued Value", "Orders Placed"], [...R.fyRows.map((m) => [m.label, m.items, m.issued.toFixed(2), m.orders.toFixed(2)] as (string | number)[]), ["FY TOTAL", R.fyTot.items, R.fyTot.issued.toFixed(2), R.fyTot.orders.toFixed(2)]]);
downloadCsv(`threadcount-report-${month}.csv`, csv);
};
const csvJournal = () => downloadCsv(`threadcount-journal-${month}.csv`, csvOf(["Cost Centre", "Department", "GL Account", "Description", "Items", "Debit"], [...R.jnRows.map((r) => [r.cc, r.dept, r.gl, r.desc, r.items, r.debit.toFixed(2)] as (string | number)[]), ["TOTAL", "", "", "", jnTotItems, jnTot.toFixed(2)]]));
const csvValuation = () => downloadCsv(`threadcount-valuation-${s.today}.csv`, `Stock valuation as at,${s.today}\n` + csvOf(["Item", "SKU", "Supplier", "Units", "Unit cost", "Value"], R.valRows.map((x) => [x.item, x.sku, x.supplier, x.units, x.cost, x.val.toFixed(2)])));
const tabCsv: Record<Tab, () => void> = {
Overview: csvOverview, Journal: csvJournal, Valuation: csvValuation,
"Top stock": () => downloadCsv(`threadcount-top-stock-${month}.csv`, csvOf(["Rank", "Item", "Supplier", "Qty (month)", "Value (month)", "Share", "Qty (FY)"], R.topRows.map((r) => [r.n, r.item, r.supplier, r.qty, r.val.toFixed(2), r.share, r.fyQty]))),
Shrinkage: () => downloadCsv(`threadcount-shrinkage-${month}.csv`, csvOf(["Date", "Counted by", "Lines counted", "Variances", "Net units", "Net value"], R.shRows.map((r) => [r.date, r.by, r.counted, r.variances, r.net, r.netVal.toFixed(2)]))),
Exceptions: () => downloadCsv(`threadcount-exceptions-${month}.csv`, csvOf(["Staff", "Group", "Cost centre", "Items (month)", "Items (FY)", "Flag"], R.excRows.map((r) => [r.who, r.group, r.cc, r.mQty, r.fyQty, r.flag]))),
Suppliers: () => downloadCsv(`threadcount-supplier-spend-${month}.csv`, csvOf(["Supplier", "Orders", "Value", "Invoices"], R.supRows.map((r) => [r.name, r.n, r.amt.toFixed(2), r.invoices]))),
Approvals: () => downloadCsv(`threadcount-approvals-outstanding-${s.today}.csv`, csvOf(["Staff", "Ward", "Approved by", "Date", "Sets approved", "Collected", "Remaining"], R.apprRows.map((r) => [r.who, r.dept, r.by, r.date, r.sets, r.used, r.rem]))),
"Pre-loved": () => downloadCsv(`threadcount-preloved-${month}.csv`, `Pre-loved issues ${month}\n` + csvOf(["Date", "Staff", "Item", "Size", "Qty", "Value saved"], R.plIssueRows.map((r) => [r.date, r.who, r.item, r.size, r.qty, r.saved.toFixed(2)])) + "\nHand-ins\n" + csvOf(["Date", "Staff", "Received by", "Good", "Rag", "Credit"], R.hiRows.map((r) => [r.date, r.who, r.by, r.good, r.rag, r.credit])) + "\nPool snapshot\n" + csvOf(["Item", "Sizes", "Total"], R.plPoolRows.map((r) => [r.item, r.sizes, r.total]))),
};
const C = (t: string, r = false): Col => ({ t, r });
const tabPrint: Record<Tab, () => void> = {
Overview: () => printDoc(`Cost centre report — ${mLbl}`, meta, [
{ h: "Summary", html: tbl([C(""), C("", true)], [["Issued value (period)", money(R.totAmt)], ["Items issued", R.totItems], ["Supplier orders placed", money(R.ordSpend)], ["vs previous month", money(R.totPrev)]]) },
{ h: "Issued value by cost centre", html: tbl([C("CC"), C("Department"), C("Items", true), C("This period", true), C("Prev", true), C("Δ", true)], [...R.ccRows.map((r) => [r.cc, r.dept, r.items, money(r.amt), money(r.prev), signedMoney(r.delta)] as (string | number)[]), ["TOTAL", "", R.totItems, money(R.totAmt), money(R.totPrev), ""]]) },
{ h: "By staff group", html: tbl([C("Group"), C("Items", true), C("Value", true)], R.groupRows.map((g) => [g.g, g.items, money(g.amt)])) },
{ h: "By staff member", html: tbl([C("Staff"), C("CC"), C("Items", true), C("Value", true)], R.staffRows.map((r) => [r.who, r.cc, r.items, money(r.amt)])) },
{ h: "Financial year", html: tbl([C("Month"), C("Items", true), C("Issued", true), C("Orders", true)], [...R.fyRows.map((m) => [m.label, m.items, money(m.issued), money(m.orders)] as (string | number)[]), ["FY TOTAL", R.fyTot.items, money(R.fyTot.issued), money(R.fyTot.orders)]]) },
]),
Journal: () => printDoc(`End-of-month journal — ${mLbl}`, meta, [{ h: `One debit per cost centre — GL ${R.glAcct}`, html: tbl([C("CC"), C("Department"), C("GL"), C("Description"), C("Items", true), C("Debit", true)], [...R.jnRows.map((r) => [r.cc, r.dept, r.gl, r.desc, r.items, money(r.debit)] as (string | number)[]), ["TOTAL", "", "", "", jnTotItems, money(jnTot)]]) }]),
"Top stock": () => printDoc(`Top stock — ${mLbl}`, meta, [{ h: "Most issued items", html: tbl([C("#"), C("Item"), C("Supplier"), C("Qty", true), C("Value", true), C("Share", true), C("Qty FY", true)], R.topRows.map((r) => [r.n, r.item, r.supplier, r.qty, money(r.val), r.share, r.fyQty])) }]),
Valuation: () => printDoc(`Stock valuation — as at ${fmtDate(s.today)}`, meta, [{ h: "On-hand value by item", html: tbl([C("Item"), C("SKU"), C("Supplier"), C("Units", true), C("Unit cost", true), C("Value", true)], [...R.valRows.map((r) => [r.item, r.sku, r.supplier, r.units, money(r.cost), money(r.val)] as (string | number)[]), ["TOTAL", "", "", R.valTotUnits, "", money(R.valTot)]]) }]),
Shrinkage: () => printDoc(`Stocktake variance / shrinkage — FY to end of ${mLbl}`, meta, [{ h: `${R.shRows.length} stocktakes · net ${signedInt(R.shU)} units · ${signedMoney(R.shV)}`, html: tbl([C("Date"), C("Counted by"), C("Lines", true), C("Variances", true), C("Net units", true), C("Net value", true)], R.shRows.map((r) => [fmtDate(r.date), r.by, r.counted, r.variances, signedInt(r.net), signedMoney(r.netVal)])) }]),
Exceptions: () => printDoc(`Staff exceptions — ${mLbl}`, meta, [{ h: `Past ${R.cap} sets, outside their staff group or not their uniform style on an override, or ≥ ${R.excThreshold} items this month`, html: tbl([C("Staff"), C("Group"), C("CC"), C("Month", true), C("FY", true), C("Flag")], R.excRows.map((r) => [r.who, r.group, r.cc, r.mQty, r.fyQty, r.flag])) }]),
Suppliers: () => printDoc(`Supplier spend — ${mLbl}`, meta, [{ h: "Orders placed this period", html: tbl([C("Supplier"), C("Orders", true), C("Value", true), C("Invoices")], R.supRows.map((r) => [r.name, r.n, money(r.amt), r.invoices])) }]),
Approvals: () => printDoc(`Uncollected manager's approvals — as at ${fmtDate(s.today)}`, meta, [{ h: `${R.apprTot} sets outstanding`, html: tbl([C("Staff"), C("Ward"), C("Approved by"), C("Date"), C("Sets", true), C("Collected", true), C("Remaining", true)], R.apprRows.map((r) => [r.who, r.dept, r.by, fmtDate(r.date), r.sets, r.used, r.rem])) }]),
"Pre-loved": () => printDoc(`Pre-loved uniforms — ${mLbl}`, meta, [
{ h: `Issued free this period — saved ${money(R.plSaved)}`, html: tbl([C("Date"), C("Staff"), C("Item"), C("Size"), C("Qty", true), C("Value saved", true)], R.plIssueRows.map((r) => [fmtDate(r.date), r.who, r.item, r.size, r.qty, money(r.saved)])) },
{ h: `Hand-ins this period · ${R.ragMonth} to rag disposal`, html: tbl([C("Date"), C("Staff"), C("Received by"), C("Good", true), C("Rag", true), C("Credit")], R.hiRows.map((r) => [fmtDate(r.date), r.who, r.by, r.good, r.rag, r.credit])) },
{ h: `Pool snapshot — ${R.plPoolTotal} items at $0 book value`, html: tbl([C("Item"), C("Sizes"), C("Total", true)], R.plPoolRows.map((r) => [r.item, r.sizes, r.total])) },
]),
};
function printEomPack() {
const sections = [
{ h: "Summary", html: tbl([C(""), C("", true), C(""), C("", true)], [["Issued value", money(R.totAmt), "Items issued", R.totItems], ["Supplier orders placed", money(R.ordSpend), "Stock on hand value", money(R.valTot)], ["Shrinkage (FY to end of month)", signedMoney(R.shV), "Stocktakes counted (FY)", R.shRows.length]]) },
{ h: "Cost centre summary", html: tbl([C("CC"), C("Department"), C("Items", true), C("Value", true)], [...R.jnRows.map((r) => [r.cc, r.dept, r.items, money(r.debit)] as (string | number)[]), ["TOTAL", "", jnTotItems, money(jnTot)]]) },
{ h: `Journal — one debit per cost centre (GL ${R.glAcct})`, html: tbl([C("CC"), C("Description"), C("Debit", true)], R.jnRows.map((r) => [r.cc, r.desc, money(r.debit)])) },
{ h: "Top stock", html: tbl([C("Item"), C("Qty", true), C("Value", true)], R.topRows.slice(0, 10).map((r) => [r.item, r.qty, money(r.val)])) },
];
// Finance is promised shrinkage in this pack, and the net figure is in the summary above every
// month. The count-by-count table only turns up when counts were actually filed, the same rule
// the exceptions and approvals sections below follow — a heading over an empty table tells
// finance nothing and costs them a page.
if (R.shRows.length) sections.push({ h: `Shrinkage — stocktake variance, FY to end of ${mLbl} · net ${signedInt(R.shU)} units · ${signedMoney(R.shV)}`, html: tbl([C("Date"), C("Counted by"), C("Variances", true), C("Net units", true), C("Net value", true)], R.shRows.map((r) => [fmtDate(r.date), r.by, r.variances, signedInt(r.net), signedMoney(r.netVal)])) });
if (R.excRows.length) sections.push({ h: "Staff exceptions", html: tbl([C("Staff"), C("Cost centre"), C("Flag")], R.excRows.map((r) => [r.who, r.cc, r.flag])) });
if (R.apprRows.length) sections.push({ h: "Uncollected manager's approvals", html: tbl([C("Staff"), C("Approved by"), C("Remaining sets", true)], R.apprRows.map((r) => [r.who, r.by, r.rem])) });
printDoc(`Month-end pack — ${mLbl}`, meta, sections);
}
const delta = (d: number) => <span style={{ color: d > 0 ? "var(--color-accent-700)" : "var(--color-neutral-700)" }}>{(d >= 0 ? "+" : "") + money(Math.abs(d)).slice(1)}</span>;
const R2 = (n: number) => ({ textAlign: "right" as const, fontWeight: n });
/* A ward manager rings the linen room asking why their number doubled this month, and until now
the coordinator had nothing on the screen to answer with. The cost centre is a button: it opens
the issues that make the figure beside it — who, which garment, which size, when, what it cost.
It is the cell and not the row because a row that only answers to a click is unreachable from
the keyboard, and its name carries the figure so it is clear what the button opens. */
const drillBtn = (cc: string, dept: string, keys: string[], items: number, amt: number) => {
// The Overview prints an em dash for staff who have no cost centre; the journal calls those
// UNALLOCATED, and that is the word to say out loud rather than "issues behind —".
const name = cc === "—" ? "UNALLOCATED" : cc;
return (
<button type="button" aria-haspopup="dialog" aria-label={`Show the ${items} item${items === 1 ? "" : "s"} issued behind ${name}${money(amt)} in ${mLbl}`}
onClick={() => setDrill({ cc: name, dept, keys })}
style={{ background: "none", border: 0, padding: 0, font: "inherit", fontWeight: 700, color: "inherit", textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>{cc}</button>
);
};
return (
<section>
<PageHead eyebrow="Finance" title="Reports">
<select className="input" aria-label="Reporting month" value={month} onChange={(e) => setMonth(e.target.value)}>{R.repMonths.map((m) => <option key={m} value={m}>{monthLabel(m)}</option>)}</select>
<button className="btn btn-secondary" onClick={printEomPack}>Month-end pack</button>
<button className="btn btn-ghost" onClick={tabCsv[tab]}>Export CSV</button>
<button className="btn btn-ghost" onClick={tabPrint[tab]}>Print</button>
</PageHead>
<div className="seg" style={{ display: "inline-flex", flexWrap: "wrap", marginTop: "var(--space-4)" }}>
{TABS.map((t) => <button key={t} className={"seg-opt" + (tab === t ? " btn-primary" : "")} onClick={() => setTab(t)}>{t}</button>)}
</div>
{tab === "Overview" && (
<>
{/* Five figures rather than the four that were here: the pre-loved pool saves the ward
real money every month and it was a sentence under the strip, which is not where
anybody looks for a number. */}
<KpiStrip items={[
{ val: money(R.totAmt), label: "Issued value (period)", note: "Each garment at what it cost the day it was issued" },
{ val: R.totItems, label: "Items issued", note: `Across ${R.ccRows.length} cost centre${R.ccRows.length === 1 ? "" : "s"}` },
{ val: money(R.ordSpend), label: "Supplier orders placed", note: "Drafts are not spend until they are sent" },
{ val: money(R.totPrev), label: "vs previous month", note: R.totAmt === R.totPrev ? "Level with last month" : `${R.totAmt > R.totPrev ? "Up" : "Down"} ${money(Math.abs(R.totAmt - R.totPrev))} on last month` },
{ val: R.plQty, label: "Pre-loved issued (free)", note: `Saved ${money(R.plSaved)} at catalogue cost` },
]} />
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "3fr 2fr", gap: "var(--space-8)" }}>
<div>
<Panel title="Issued value by cost centre" aside={`${mLbl} against the month before`}>
<div className="tc-panel-body">
<div className="table-wrap"><table className="table">
<thead><tr>{th("Cost centre")}{th("Department")}{th("Items", true)}{th("This period", true)}{th("Prev", true)}{th("Δ", true)}</tr></thead>
<tbody>
{R.ccRows.map((r) => <tr key={r.cc + r.dept}><td style={{ fontWeight: 700 }}>{drillBtn(r.cc, r.dept, [r.key], r.items, r.amt)}</td><td>{r.dept}</td><td style={R2(400)}>{r.items}</td><td style={R2(700)}>{money(r.amt)}</td><td style={{ textAlign: "right", color: "var(--color-neutral-700)" }}>{money(r.prev)}</td><td style={{ textAlign: "right" }}>{delta(r.delta)}</td></tr>)}
<tr><td style={{ fontWeight: 800 }}>TOTAL</td><td></td><td style={R2(800)}>{R.totItems}</td><td style={R2(800)}>{money(R.totAmt)}</td><td style={{ textAlign: "right", color: "var(--color-neutral-700)" }}>{money(R.totPrev)}</td><td></td></tr>
</tbody>
</table></div>
{R.ccRows.length === 0 && <Empty pad={4}>No issues recorded in this period yet.</Empty>}
</div>
</Panel>
<Panel title="Financial year summary" aside={`To the end of ${mLbl}`}>
<div className="tc-panel-body">
<div className="table-wrap"><table className="table">
<thead><tr>{th("Month")}{th("Items issued", true)}{th("Issued value", true)}{th("Orders placed", true)}</tr></thead>
<tbody>
{R.fyRows.map((m) => <tr key={m.m}><td style={{ fontWeight: 600 }}>{m.label}</td><td style={R2(400)}>{m.items}</td><td style={R2(700)}>{money(m.issued)}</td><td style={{ textAlign: "right", color: "var(--color-neutral-700)" }}>{money(m.orders)}</td></tr>)}
<tr><td style={{ fontWeight: 800 }}>FY TOTAL</td><td style={R2(800)}>{R.fyTot.items}</td><td style={R2(800)}>{money(R.fyTot.issued)}</td><td style={R2(800)}>{money(R.fyTot.orders)}</td></tr>
</tbody>
</table></div>
</div>
</Panel>
</div>
<div>
<Panel title="By staff group">
{R.groupRows.length === 0 ? <div className="tc-panel-body"><Empty pad={3}>Nothing issued in this period.</Empty></div> : (
<div className="tc-panel-list">
{R.groupRows.map((g) => (
<div key={g.g} className="tc-row">
<div className="tc-row-main"><div className="tc-row-name">{g.g}</div><div className="tc-row-meta">{g.items} item{g.items === 1 ? "" : "s"}</div></div>
<div className="tc-row-fig">{money(g.amt)}</div>
</div>
))}
</div>
)}
</Panel>
<Panel title="By staff member">
{R.staffRows.length === 0 ? <div className="tc-panel-body"><Empty pad={3}>Nothing issued in this period.</Empty></div> : (
<div className="tc-panel-list">
{R.staffRows.map((r, i) => (
<div key={i} className="tc-row">
<div className="tc-row-main"><div className="tc-row-name">{r.who}</div><div className="tc-row-meta">{r.cc || "no cost centre"} · {r.items} item{r.items === 1 ? "" : "s"}</div></div>
<div className="tc-row-fig">{money(r.amt)}</div>
</div>
))}
</div>
)}
</Panel>
<Panel title="Supplier orders this period">
{R.supRows.length === 0 ? <div className="tc-panel-body"><Empty pad={3}>No supplier orders placed this period.</Empty></div> : (
<div className="tc-panel-list">
{R.supRows.map((r) => (
<div key={r.name} className="tc-row">
<div className="tc-row-main"><div className="tc-row-name">{r.name}</div><div className="tc-row-meta">{r.n} order{r.n === 1 ? "" : "s"}</div></div>
<div className="tc-row-fig">{money(r.amt)}</div>
</div>
))}
</div>
)}
</Panel>
<Panel title="Issued value" aside="Last 6 months">
<div className="tc-panel-body">
<div style={{ display: "flex", alignItems: "flex-end", gap: "var(--space-2)", height: 120 }}>
{/* Each bar changes the month the whole page is reporting on, so it is a button —
a clickable <div> put the only way of moving between months out of reach of the
keyboard. The bar itself is decoration; the name says the month and the figure. */}
{R.trend.map((b) => (
<button type="button" key={b.m} aria-label={`Show ${monthLabel(b.m)}${money(b.amt)} issued`} aria-current={b.sel ? "true" : undefined}
style={{ flex: 1, display: "flex", flexDirection: "column", justifyContent: "flex-end", height: "100%", gap: 4, cursor: "pointer", background: "none", border: 0, padding: 0, font: "inherit", color: "inherit" }} onClick={() => setMonth(b.m)}>
<span style={{ display: "block", width: "100%", fontSize: 10, textAlign: "center", color: "var(--color-neutral-700)", whiteSpace: "nowrap", overflow: "hidden" }}>{b.amt ? money(b.amt) : ""}</span>
<span aria-hidden="true" style={{ display: "block", width: "100%", height: b.h, background: b.sel ? "var(--color-accent)" : "var(--color-neutral-300)" }} />
<span style={{ display: "block", width: "100%", fontSize: 10, textAlign: "center", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-700)" }}>{b.label}</span>
</button>
))}
</div>
</div>
</Panel>
</div>
</div>
</>
)}
{tab === "Journal" && (
/* A staff member with no cost centre lands in UNALLOCATED, and finance cannot post that
line — so the panel carries the rule and the mark rather than leaving the warning to a
red sentence under a table nobody scrolls to. */
<Panel title={`End-of-month journal — ${mLbl}`} aside={`GL ${R.glAcct}`} flag={R.jnUnallocated}
right={<button className="btn btn-secondary" onClick={csvJournal}>Export journal CSV</button>}>
<div className="tc-panel-body">
<div className="table-wrap"><table className="table">
<thead><tr>{th("Cost centre")}{th("Department")}{th("GL account")}{th("Description")}{th("Items", true)}{th("Debit", true)}</tr></thead>
<tbody>
{R.jnRows.map((r) => <tr key={r.cc + r.dept}><td style={{ fontWeight: 700 }}>{drillBtn(r.cc, r.dept, r.keys, r.items, r.debit)}</td><td>{r.dept}</td><td>{r.gl}</td><td>{r.desc}</td><td style={R2(400)}>{r.items}</td><td style={R2(700)}>{money(r.debit)}</td></tr>)}
<tr><td style={{ fontWeight: 800 }}>TOTAL</td><td></td><td></td><td></td><td style={R2(800)}>{jnTotItems}</td><td style={R2(800)}>{money(jnTot)}</td></tr>
</tbody>
</table></div>
</div>
<div className="tc-panel-foot" style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>Set the GL account and description under Settings General.{R.jnUnallocated && <b style={{ color: "var(--color-accent-700)" }}> UNALLOCATED = staff with no cost centre set their department or override on the Staff Register before posting.</b>}</div>
</Panel>
)}
{tab === "Top stock" && (
<Panel title={`Top stock — ${mLbl}`} aside="Most issued items">
<div className="tc-panel-body">
<div className="table-wrap"><table className="table">
<thead><tr>{th("#")}{th("Item")}{th("Supplier")}{th("Qty (month)", true)}{th("Value (month)", true)}{th("Share", true)}{th("Qty (FY)", true)}</tr></thead>
<tbody>{R.topRows.map((r) => <tr key={r.n}><td style={{ color: "var(--color-neutral-600)" }}>{r.n}</td><td style={{ fontWeight: 600 }}>{r.item}</td><td>{r.supplier}</td><td style={R2(700)}>{r.qty}</td><td style={R2(400)}>{money(r.val)}</td><td style={{ textAlign: "right", color: "var(--color-neutral-700)" }}>{r.share}</td><td style={{ textAlign: "right", color: "var(--color-neutral-700)" }}>{r.fyQty}</td></tr>)}</tbody>
</table></div>
{R.topRows.length === 0 && <Empty pad={4}>Nothing issued this period.</Empty>}
</div>
</Panel>
)}
{tab === "Valuation" && (
<Panel title="Stock valuation — as at today" flag={R.negSizes > 0}
aside={R.negSizes > 0 ? `${R.negSizes} size${R.negSizes === 1 ? "" : "s"} negative on hand` : "Priced at catalogue cost"}
right={<button className="btn btn-secondary" onClick={csvValuation}>Export CSV</button>}>
<div className="tc-panel-body">
<div className="table-wrap"><table className="table">
<thead><tr>{th("Item")}{th("SKU")}{th("Supplier")}{th("Units on hand", true)}{th("Unit cost", true)}{th("Value", true)}</tr></thead>
<tbody>
{R.valRows.map((r, i) => <tr key={i}><td style={{ fontWeight: 600 }}>{r.item}</td><td style={{ fontSize: 12 }}>{r.sku}</td><td>{r.supplier}</td><td style={R2(400)}>{r.units}</td><td style={R2(400)}>{money(r.cost)}</td><td style={R2(700)}>{money(r.val)}</td></tr>)}
<tr><td style={{ fontWeight: 800 }}>TOTAL</td><td></td><td></td><td style={R2(800)}>{R.valTotUnits}</td><td></td><td style={R2(800)}>{money(R.valTot)}</td></tr>
</tbody>
</table></div>
</div>
{R.negSizes > 0 && <div className="tc-panel-foot" style={{ fontSize: 12, color: "var(--color-accent-700)", fontWeight: 600 }}>{R.negSizes} size{R.negSizes === 1 ? "" : "s"} are negative on hand and are counted as 0 in this valuation run a stocktake or record the missing receipt.</div>}
</Panel>
)}
{tab === "Shrinkage" && (
<>
{/* Stock that has gone missing is money finance has to be told about, so the two figures
that carry it take the rule and the mark when the net is down, not just a red number. */}
<KpiStrip items={[
{ val: R.shRows.length, label: "Stocktakes this FY", note: `To the end of ${mLbl}` },
{ val: signedInt(R.shU), label: "Net variance (FY)", flag: R.shV < 0, note: "Units counted against units the system expected" },
{ val: signedMoney(R.shV), label: "Net value (FY)", flag: R.shV < 0, note: R.shV < 0 ? "Stock short at catalogue cost" : "At catalogue cost" },
]} />
<Panel title={`Stocktake variance — FY to end of ${mLbl}`} aside={`${R.shRows.length} count${R.shRows.length === 1 ? "" : "s"} filed`}>
<div className="tc-panel-body">
<div className="table-wrap"><table className="table">
<thead><tr>{th("Date")}{th("Counted by")}{th("Lines counted", true)}{th("Variances", true)}{th("Net units", true)}{th("Net value", true)}</tr></thead>
<tbody>{R.shRows.map((r, i) => <tr key={i}><td style={{ fontWeight: 600 }}>{fmtDate(r.date)}</td><td>{r.by}</td><td style={R2(400)}>{r.counted}</td><td style={R2(400)}>{r.variances}</td><td style={R2(400)}>{signedInt(r.net)}</td><td style={R2(700)}>{signedMoney(r.netVal)}</td></tr>)}</tbody>
</table></div>
{R.shRows.length === 0 && <Empty pad={4}>No stocktakes filed in this financial year up to the end of this month.</Empty>}
</div>
</Panel>
</>
)}
{tab === "Exceptions" && (
<Panel title={`Staff exceptions — ${mLbl}`} flag={R.excRows.length > 0}
aside={R.excRows.length > 0 ? `${R.excRows.length} to look at` : "No overrides, nobody at the volume threshold"}>
<div className="tc-panel-body">
<div className="table-wrap"><table className="table">
<thead><tr>{th("Staff")}{th("Group")}{th("Cost centre")}{th("Items (month)", true)}{th("Items (FY)", true)}{th("Flag")}</tr></thead>
<tbody>{R.excRows.map((r, i) => <tr key={i}><td style={{ fontWeight: 600 }}>{r.who}</td><td>{r.group}</td><td>{r.cc}</td><td style={R2(400)}>{r.mQty}</td><td style={R2(400)}>{r.fyQty}</td><td><span style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>{r.flags.map((f, j) => <span key={j} className="tag tag-flag">{f}</span>)}</span></td></tr>)}</tbody>
</table></div>
{R.excRows.length === 0 && <Empty pad={4}>No exceptions this period.</Empty>}
</div>
<div className="tc-panel-foot" style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>Items (FY) is a running tally, not an allowance.</div>
</Panel>
)}
{tab === "Suppliers" && (
<Panel title={`Supplier spend — ${mLbl}`} aside="Orders placed this period">
<div className="tc-panel-body">
<div className="table-wrap"><table className="table">
<thead><tr>{th("Supplier")}{th("Orders", true)}{th("Value", true)}{th("Invoices")}</tr></thead>
<tbody>{R.supRows.map((r) => <tr key={r.name}><td style={{ fontWeight: 600 }}>{r.name}</td><td style={R2(400)}>{r.n}</td><td style={R2(700)}>{money(r.amt)}</td><td style={{ fontSize: 12 }}>{r.invoices}</td></tr>)}</tbody>
</table></div>
{R.supRows.length === 0 && <Empty pad={4}>No supplier orders placed this period.</Empty>}
</div>
</Panel>
)}
{tab === "Pre-loved" && (
<>
<Panel title={`Pre-loved issues — ${mLbl}`} aside={`Saved ${money(R.plSaved)}`}>
<div className="tc-panel-body">
{R.plIssueRows.length === 0 ? <Empty pad={3}>Nothing issued from the pool this period.</Empty> : (
<div className="table-wrap"><table className="table">
<thead><tr>{th("Date")}{th("Staff")}{th("Item")}{th("Size")}{th("Qty", true)}{th("Value saved", true)}</tr></thead>
<tbody>{R.plIssueRows.map((r, i) => <tr key={i}><td style={{ fontSize: 12 }}>{fmtDate(r.date)}</td><td style={{ fontWeight: 600 }}>{r.who}</td><td>{r.item}</td><td>{r.size}</td><td style={R2(400)}>{r.qty}</td><td style={{ ...R2(400), color: "var(--color-neutral-700)" }}>{money(r.saved)}</td></tr>)}</tbody>
</table></div>
)}
</div>
</Panel>
<Panel title={`Hand-ins — ${mLbl}`} aside={`${R.ragMonth} to rag disposal`}>
<div className="tc-panel-body">
{R.hiRows.length === 0 ? <Empty pad={3}>No hand-ins recorded this period.</Empty> : (
<div className="table-wrap"><table className="table">
<thead><tr>{th("Date")}{th("Staff")}{th("Received by")}{th("Good", true)}{th("Rag", true)}{th("Allowance")}</tr></thead>
<tbody>{R.hiRows.map((r, i) => <tr key={i}><td style={{ fontSize: 12 }}>{fmtDate(r.date)}</td><td style={{ fontWeight: 600 }}>{r.who}</td><td>{r.by}</td><td style={R2(400)}>{r.good}</td><td style={R2(400)}>{r.rag}</td><td>{r.credit}</td></tr>)}</tbody>
</table></div>
)}
</div>
</Panel>
<Panel title="Pool snapshot" aside={`${R.plPoolTotal} items at $0 book value`}>
<div className="tc-panel-body">
{R.plPoolRows.length === 0 ? <Empty pad={3}>The pool is empty record a hand-in from Issue Stock or a staff profile.</Empty> : (
<div className="table-wrap"><table className="table">
<thead><tr>{th("Item")}{th("Sizes on hand")}{th("Total", true)}</tr></thead>
<tbody>{R.plPoolRows.map((r, i) => <tr key={i}><td style={{ fontWeight: 600 }}>{r.item}</td><td>{r.sizes}</td><td style={R2(700)}>{r.total}</td></tr>)}</tbody>
</table></div>
)}
</div>
</Panel>
</>
)}
{tab === "Approvals" && (
<Panel title="Managers approvals — uncollected credit" flag={R.apprTot > 0}
aside={R.apprTot > 0 ? `${R.apprTot} set${R.apprTot === 1 ? "" : "s"} outstanding` : "Everything approved has been collected"}>
<div className="tc-panel-body">
<div className="table-wrap"><table className="table">
<thead><tr>{th("Staff")}{th("Ward")}{th("Approved by")}{th("Date")}{th("Sets approved", true)}{th("Collected", true)}{th("Remaining", true)}</tr></thead>
<tbody>
{R.apprRows.map((r, i) => <tr key={i}><td style={{ fontWeight: 600 }}>{r.who}</td><td>{r.dept}</td><td>{r.by}</td><td style={{ fontSize: 12 }}>{fmtDate(r.date)}</td><td style={R2(400)}>{r.sets}</td><td style={R2(400)}>{r.used}</td><td style={{ textAlign: "right", fontWeight: 700, color: "var(--color-accent-700)" }}>{r.rem}</td></tr>)}
<tr><td style={{ fontWeight: 800 }}>TOTAL OUTSTANDING</td><td></td><td></td><td></td><td></td><td></td><td style={R2(800)}>{R.apprTot} sets</td></tr>
</tbody>
</table></div>
{R.apprRows.length === 0 && <Empty pad={4}>No uncollected approvals.</Empty>}
</div>
</Panel>
)}
{drill && (
<Dialog title={`Issues behind ${drill.cc}${mLbl}`} width={820} onClose={() => setDrill(null)}
sub={`${drill.dept} · ${drillQty} item${drillQty === 1 ? "" : "s"} · ${money(drillAmt)}`}>
{drillRows.length === 0 ? <Empty pad={3}>Nothing was issued against this cost centre in {mLbl}.</Empty> : (
<div className="table-wrap"><table className="table" style={{ marginTop: "var(--space-3)" }}>
<thead><tr>{th("Date")}{th("Staff")}{th("Item")}{th("Size")}{th("Qty", true)}{th("Unit cost", true)}{th("Value", true)}</tr></thead>
<tbody>
{drillRows.map((r, i) => <tr key={i}><td style={{ fontSize: 12 }}>{fmtDate(r.date)}</td><td style={{ fontWeight: 600 }}>{r.who}</td><td>{r.item}</td><td>{r.size}</td><td style={R2(400)}>{r.qty}</td><td style={{ ...R2(400), color: "var(--color-neutral-700)" }}>{money(r.unit)}</td><td style={R2(700)}>{money(r.amt)}</td></tr>)}
<tr><td style={{ fontWeight: 800 }}>TOTAL</td><td></td><td></td><td></td><td style={R2(800)}>{drillQty}</td><td></td><td style={R2(800)}>{money(drillAmt)}</td></tr>
</tbody>
</table></div>
)}
<div style={{ display: "flex", gap: "var(--space-2)", justifyContent: "flex-end", marginTop: "var(--space-4)" }}>
{drillRows.length > 0 && <button className="btn btn-secondary" style={{ marginRight: "auto" }} onClick={csvDrill}>Export CSV</button>}
<button className="btn btn-ghost" onClick={() => setDrill(null)}>Close</button>
</div>
</Dialog>
)}
<div style={{ marginTop: "var(--space-3)", fontSize: 12, color: "var(--color-neutral-700)" }}>Print and Export CSV follow the selected tab click a cost centre for the issues behind it.</div>
</section>
);
}