344b1701dd
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
538 lines
52 KiB
TypeScript
538 lines
52 KiB
TypeScript
"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="Manager’s 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>
|
||
);
|
||
}
|