ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from f976bd5 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
/* The four month-end steps across the top of Reports. Every state comes from monthEnd() in
|
||||
* lib/portalcounts.ts, so Today's checklist and this strip agree. */
|
||||
import Link from "next/link";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { monthEnd } from "@/lib/portalcounts";
|
||||
import { Icon } from "@/components/portal";
|
||||
import { shortDate } from "./bits";
|
||||
|
||||
const cell: React.CSSProperties = { background: "var(--color-bg)", padding: "14px 18px", minWidth: 0 };
|
||||
const state: React.CSSProperties = { marginTop: 6, fontWeight: 600, display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" };
|
||||
const inline: React.CSSProperties = { minHeight: 0, padding: 0 };
|
||||
|
||||
export default function MonthEndStrip({ month, onJournal, onPrint }: { month: string; onJournal: () => void; onPrint: () => void }) {
|
||||
const { s } = useSnap();
|
||||
const { L, byId, staffById } = useDerived();
|
||||
const me = monthEnd(s, L, byId, staffById, month);
|
||||
|
||||
return (
|
||||
<section className="tc-rep-strip" aria-label="Month-end steps">
|
||||
<div style={cell}>
|
||||
<div className="tc-lbl">1 · Deliveries booked in</div>
|
||||
{me.deliveriesOverdue > 0
|
||||
? <div style={{ ...state, color: "var(--color-accent-700)" }}><Icon name="alert" size={16} /><Link href="/app/orders/all" style={{ color: "inherit" }}>{me.deliveriesOverdue} overdue</Link></div>
|
||||
: <div style={state}><Icon name="check" size={16} />All in</div>}
|
||||
</div>
|
||||
<div style={cell}>
|
||||
<div className="tc-lbl">2 · Stock take filed</div>
|
||||
{me.stocktakeFiled
|
||||
? <div style={state}>Filed <span className="tc-mono">{shortDate(me.stocktakeFiled.date)}</span></div>
|
||||
: <div style={state}><span>Not yet ·</span><Link href="/app/stock?tab=count" className="btn btn-ghost" style={inline}>Start</Link></div>}
|
||||
</div>
|
||||
<div style={cell}>
|
||||
<div className="tc-lbl">3 · Journal</div>
|
||||
{!me.stocktakeFiled
|
||||
? <div style={{ ...state, color: "#6c6764" }}>Ready after the count</div>
|
||||
: me.unallocated > 0
|
||||
? <div style={state}><button type="button" className="btn btn-ghost" style={{ ...inline, color: "var(--color-accent-700)" }} onClick={onJournal}><span className="tc-mono">{me.unallocated}</span> unallocated</button></div>
|
||||
: <div style={state}><Icon name="check" size={16} />Ready</div>}
|
||||
</div>
|
||||
<button type="button" className="tc-rep-pack" aria-label="Print the month-end pack" onClick={onPrint}
|
||||
style={{ ...cell, background: "#201e1d", color: "#f3f2f2", border: 0, textAlign: "left", font: "inherit", cursor: "pointer" }}>
|
||||
<div className="tc-lbl" style={{ color: "#b5b1af" }}>4 · Month-end pack</div>
|
||||
<div style={state}>PDF + journal CSV + valuation</div>
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { LiveRegion } from "@/components/ui";
|
||||
import { Panel } from "@/components/portal";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { fmtDate, money } from "@/lib/compute";
|
||||
import { HeadActions, PanelEmpty, TableWrap, TOTAL, plural } from "./bits";
|
||||
import type { ReportData } from "./useReportData";
|
||||
|
||||
function SubHead({ children }: { children: React.ReactNode }) {
|
||||
return <h3 className="tc-lbl" style={{ margin: 0, padding: "12px 16px 4px", borderTop: "2px solid #201e1d" }}>{children}</h3>;
|
||||
}
|
||||
|
||||
/* The yearly figure is a reporting number, not a limit, so it lives here and nowhere else. */
|
||||
function YearlyFigure() {
|
||||
const { s, isAdmin, busy, mutate } = useSnap();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [val, setVal] = useState("");
|
||||
const [msg, setMsg] = useState<{ text: string; err: boolean } | null>(null);
|
||||
const current = s.settings.defaultEntitlement;
|
||||
|
||||
async function save() {
|
||||
const n = Number(val);
|
||||
if (!Number.isInteger(n) || n < 0) { setMsg({ text: "Enter a whole number, 0 or more.", err: true }); return; }
|
||||
const r = await mutate("settings.update", { defaultEntitlement: n });
|
||||
if (!r.ok) { setMsg({ text: r.error, err: true }); return; }
|
||||
setEditing(false);
|
||||
setMsg({ text: "Saved.", err: false });
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="tc-pp" aria-label="Yearly figure for reports">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap", padding: "11px 16px" }}>
|
||||
{!editing ? (
|
||||
<>
|
||||
<span>Yearly figure for reports · <span className="tc-mono" style={{ fontWeight: 600 }}>{current}</span> garments per person</span>
|
||||
{isAdmin && <button type="button" className="btn btn-ghost" style={{ minHeight: 0, padding: 0, marginLeft: "auto" }} onClick={() => { setVal(String(current)); setMsg(null); setEditing(true); }}>Change</button>}
|
||||
</>
|
||||
) : (
|
||||
<form style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }} onSubmit={(e) => { e.preventDefault(); void save(); }}>
|
||||
<label htmlFor="tc-rep-yearly">Yearly figure for reports</label>
|
||||
<input id="tc-rep-yearly" className="input tc-mono" type="number" min={0} step={1} inputMode="numeric" value={val} onChange={(e) => setVal(e.target.value)} style={{ width: 90 }} autoFocus />
|
||||
<span>garments per person</span>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>Save</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => { setEditing(false); setMsg(null); }}>Cancel</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<LiveRegion tone={msg?.err ? "alert" : "status"} msg={msg?.text}
|
||||
style={{ padding: "0 16px 10px", fontSize: 12, fontWeight: 600, color: msg?.err ? "var(--color-accent-700)" : "var(--color-neutral-700)" }} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PeopleTab({ d }: { d: ReportData }) {
|
||||
const { R, mLbl, csv, print } = d;
|
||||
return (
|
||||
<div className="tc-rep-stack">
|
||||
<Panel title="Exceptions" flag={R.excRows.length > 0}
|
||||
aside={<HeadActions name="staff exceptions" aside={<>threshold <span className="tc-mono">{R.excThreshold}</span> items/month</>} onCsv={csv.exceptions} onPrint={print.exceptions} />}>
|
||||
{R.excRows.length === 0 ? <PanelEmpty>No exceptions in {mLbl}.</PanelEmpty> : (
|
||||
<TableWrap>
|
||||
<thead><tr><th>Staff</th><th>Group</th><th>Cost centre</th><th className="num">Items (month)</th><th className="num">Items (FY)</th><th>Flag</th></tr></thead>
|
||||
<tbody>{R.excRows.map((r, i) => <tr key={i}><td>{r.who}</td><td>{r.group}</td><td className="tc-mono">{r.cc || "—"}</td><td className="num">{r.mQty}</td><td className="num">{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>
|
||||
</TableWrap>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Approvals outstanding" flag={R.apprTot > 0}
|
||||
aside={<HeadActions name="approvals outstanding" aside={R.apprTot > 0 ? `${plural(R.apprTot, "set")} outstanding` : undefined} onCsv={csv.approvals} onPrint={print.approvals} />}>
|
||||
{R.apprRows.length === 0 ? <PanelEmpty>No uncollected approvals.</PanelEmpty> : (
|
||||
<TableWrap>
|
||||
<thead><tr><th>Staff</th><th>Ward</th><th>Approved by</th><th>Date</th><th className="num">Sets approved</th><th className="num">Collected</th><th className="num">Remaining</th></tr></thead>
|
||||
<tbody>
|
||||
{R.apprRows.map((r, i) => <tr key={i}><td>{r.who}</td><td>{r.dept}</td><td>{r.by}</td><td className="tc-mono">{fmtDate(r.date)}</td><td className="num">{r.sets}</td><td className="num">{r.used}</td><td className="num" style={{ fontWeight: 600, color: "var(--color-accent-700)" }}>{r.rem}</td></tr>)}
|
||||
<tr><td style={TOTAL}>TOTAL OUTSTANDING</td><td></td><td></td><td></td><td></td><td></td><td className="num" style={TOTAL}>{R.apprTot}</td></tr>
|
||||
</tbody>
|
||||
</TableWrap>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Pre-loved" aside={<HeadActions name="pre-loved issues, hand-ins and pool" aside={<>saved <span className="tc-mono">{money(R.plSaved)}</span></>} onCsv={csv.preloved} onPrint={print.preloved} />}>
|
||||
<h3 className="tc-lbl" style={{ margin: 0, padding: "12px 16px 4px" }}>Issued free · <span className="tc-mono">{R.plQty}</span></h3>
|
||||
{R.plIssueRows.length === 0 ? <PanelEmpty>Nothing issued from the pool in {mLbl}.</PanelEmpty> : (
|
||||
<TableWrap>
|
||||
<thead><tr><th>Date</th><th>Staff</th><th>Item</th><th>Size</th><th className="num">Qty</th><th className="num">Value saved</th></tr></thead>
|
||||
<tbody>{R.plIssueRows.map((r, i) => <tr key={i}><td className="tc-mono">{fmtDate(r.date)}</td><td>{r.who}</td><td>{r.item}</td><td className="tc-mono">{r.size}</td><td className="num">{r.qty}</td><td className="num">{money(r.saved)}</td></tr>)}</tbody>
|
||||
</TableWrap>
|
||||
)}
|
||||
<SubHead>Hand-ins · <span className="tc-mono">{R.ragMonth}</span> to rag</SubHead>
|
||||
{R.hiRows.length === 0 ? <PanelEmpty>No hand-ins in {mLbl}.</PanelEmpty> : (
|
||||
<TableWrap>
|
||||
<thead><tr><th>Date</th><th>Staff</th><th>Received by</th><th className="num">Good</th><th className="num">Rag</th><th>Allowance</th></tr></thead>
|
||||
<tbody>{R.hiRows.map((r, i) => <tr key={i}><td className="tc-mono">{fmtDate(r.date)}</td><td>{r.who}</td><td>{r.by}</td><td className="num">{r.good}</td><td className="num">{r.rag}</td><td>{r.credit}</td></tr>)}</tbody>
|
||||
</TableWrap>
|
||||
)}
|
||||
<SubHead>Pool today · <span className="tc-mono">{R.plPoolTotal}</span> at $0</SubHead>
|
||||
{R.plPoolRows.length === 0 ? <PanelEmpty>The pool is empty.</PanelEmpty> : (
|
||||
<TableWrap>
|
||||
<thead><tr><th>Item</th><th>Sizes on hand</th><th className="num">Total</th></tr></thead>
|
||||
<tbody>{R.plPoolRows.map((r, i) => <tr key={i}><td>{r.item}</td><td className="tc-mono">{r.sizes}</td><td className="num" style={{ fontWeight: 600 }}>{r.total}</td></tr>)}</tbody>
|
||||
</TableWrap>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<YearlyFigure />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Dialog } from "@/components/ui";
|
||||
import { Bar, Figures, Panel } from "@/components/portal";
|
||||
import { csvEsc, csvOf, fmtDate, money, monthLabel, prevMonth } from "@/lib/compute";
|
||||
import { downloadCsv } from "@/lib/print";
|
||||
import { HeadActions, PanelEmpty, TableWrap, TOTAL, plural } from "./bits";
|
||||
import type { ReportData } from "./useReportData";
|
||||
|
||||
export const JOURNAL_ID = "tc-rep-journal";
|
||||
|
||||
export default function SpendTab({ d, onMonth }: { d: ReportData; onMonth: (m: string) => void }) {
|
||||
const { R, month, mLbl, csv, print, jnTotItems, jnTot } = d;
|
||||
// Only the keys are held: the lines are recounted from the snapshot on every render, so the
|
||||
// dialog still agrees with the row that opened it if stock moves while it is open.
|
||||
const [drill, setDrill] = useState<{ cc: string; dept: string; keys: string[] } | null>(null);
|
||||
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 open = (cc: string, dept: string, keys: string[]) => setDrill({ cc: cc === "—" ? "UNALLOCATED" : cc, dept, keys });
|
||||
const drillBtn = (cc: string, dept: string, keys: string[], items: number, amt: number) => {
|
||||
const name = cc === "—" ? "UNALLOCATED" : cc;
|
||||
return (
|
||||
<button type="button" className="tc-rep-drill tc-mono" aria-haspopup="dialog" aria-label={`Show the ${plural(items, "item")} issued behind ${name}, ${money(amt)} in ${mLbl}`}
|
||||
onClick={(e) => { e.stopPropagation(); open(cc, dept, keys); }}>{cc}</button>
|
||||
);
|
||||
};
|
||||
|
||||
const pm = prevMonth(month);
|
||||
const pct = R.totPrev > 0 ? Math.round(((R.totAmt - R.totPrev) / R.totPrev) * 100) : null;
|
||||
const pctText = pct === null ? "—" : (pct > 0 ? "+" : pct < 0 ? "−" : "") + Math.abs(pct) + "%";
|
||||
const ccShown = R.ccRows.filter((r) => r.items > 0 || r.amt > 0);
|
||||
const ccMax = Math.max(0, ...ccShown.map((r) => r.amt));
|
||||
const monthOnly = monthLabel(month, { month: "long" });
|
||||
|
||||
return (
|
||||
<div className="tc-rep-stack">
|
||||
<Figures items={[
|
||||
{ value: money(R.totAmt), label: "Issued value", note: <>vs {monthLabel(pm, { month: "long" })} <span className="tc-mono">{pctText}</span></> },
|
||||
{ value: R.totItems + R.plQty, label: "Garments issued", note: <><span className="tc-mono">{R.plQty}</span> of them pre-loved</> },
|
||||
{ value: money(R.ordSpend), label: "Ordered from suppliers", note: <><span className="tc-mono">{R.ordCount}</span> order{R.ordCount === 1 ? "" : "s"}</> },
|
||||
]} />
|
||||
|
||||
<div className="tc-grid tc-rep-grid">
|
||||
<Panel title="By cost centre" aside={<HeadActions name="issued value by cost centre" aside="click a row for the lines" onCsv={csv.costCentres} onPrint={print.costCentres} />}>
|
||||
{ccShown.length === 0 ? <PanelEmpty>No issues recorded in {mLbl}.</PanelEmpty> : (
|
||||
<TableWrap>
|
||||
<thead><tr><th>Cost centre</th><th>Ward</th><th style={{ width: "34%" }}><span className="sr-only">Share of the largest</span></th><th className="num">Items</th><th className="num">Value</th></tr></thead>
|
||||
<tbody>
|
||||
{ccShown.map((r) => (
|
||||
<tr key={r.key} onClick={() => open(r.cc, r.dept, [r.key])} style={{ cursor: "pointer" }}>
|
||||
<td>{drillBtn(r.cc, r.dept, [r.key], r.items, r.amt)}</td>
|
||||
<td>{r.dept}</td>
|
||||
<td><Bar value={r.amt} max={ccMax} label={`${money(r.amt)} of ${money(ccMax)}`} /></td>
|
||||
<td className="num">{r.items}</td>
|
||||
<td className="num" style={{ fontWeight: 600 }}>{money(r.amt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr><td style={TOTAL}>TOTAL</td><td></td><td></td><td className="num" style={TOTAL}>{R.totItems}</td><td className="num" style={TOTAL}>{money(R.totAmt)}</td></tr>
|
||||
</tbody>
|
||||
</TableWrap>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="By staff group" aside={monthOnly}>
|
||||
{R.groupRows.length === 0 ? <PanelEmpty>Nothing issued in {mLbl}.</PanelEmpty> : (
|
||||
<TableWrap>
|
||||
<thead><tr><th>Group</th><th className="num">People</th><th className="num">Value</th></tr></thead>
|
||||
<tbody>{R.groupRows.map((g) => <tr key={g.g}><td>{g.g}</td><td className="num">{g.people}</td><td className="num">{money(g.amt)}</td></tr>)}</tbody>
|
||||
</TableWrap>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="tc-grid tc-rep-grid">
|
||||
<Panel title="By staff member" aside={<HeadActions name="issued value by staff member" onCsv={csv.staff} onPrint={print.staff} />}>
|
||||
{R.staffRows.length === 0 ? <PanelEmpty>Nothing issued in {mLbl}.</PanelEmpty> : (
|
||||
<TableWrap>
|
||||
<thead><tr><th>Staff</th><th>Cost centre</th><th className="num">Items</th><th className="num">Value</th></tr></thead>
|
||||
<tbody>{R.staffRows.map((r, i) => <tr key={i}><td>{r.who}</td><td className="tc-mono">{r.cc || "—"}</td><td className="num">{r.items}</td><td className="num" style={{ fontWeight: 600 }}>{money(r.amt)}</td></tr>)}</tbody>
|
||||
</TableWrap>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Issued value · last 6 months">
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 8, height: 140, padding: "12px 16px" }}>
|
||||
{R.trend.map((b) => (
|
||||
<button type="button" key={b.m} className="tc-rep-trend" aria-label={`Show ${monthLabel(b.m)}, ${money(b.amt)} issued`} aria-pressed={b.sel} onClick={() => onMonth(b.m)}>
|
||||
<span className="tc-mono" style={{ fontSize: 10, 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-600)" : "#201e1d" }} />
|
||||
<span className="tc-lbl" style={{ fontSize: 10 }}>{b.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel id={JOURNAL_ID} title="Journal" flag={R.jnUnallocated}
|
||||
aside={<HeadActions name="the journal" aside={<>GL <span className="tc-mono">{R.glAcct}</span></>} onCsv={csv.journal} csvText="Export journal CSV" csvSecondary onPrint={print.journal} />}
|
||||
foot={R.jnUnallocated ? <span className="tc-meta-line" style={{ color: "var(--color-accent-700)", fontWeight: 600 }}><span className="tc-mark" aria-hidden="true" />UNALLOCATED = no cost centre</span> : undefined}>
|
||||
{R.jnRows.length === 0 ? <PanelEmpty>No issues to journal in {mLbl}.</PanelEmpty> : (
|
||||
<TableWrap>
|
||||
<thead><tr><th>Cost centre</th><th>Department</th><th>GL account</th><th>Description</th><th className="num">Items</th><th className="num">Debit</th></tr></thead>
|
||||
<tbody>
|
||||
{R.jnRows.map((r) => <tr key={r.cc + r.dept}><td>{drillBtn(r.cc, r.dept, r.keys, r.items, r.debit)}</td><td>{r.dept}</td><td className="tc-mono">{r.gl}</td><td>{r.desc}</td><td className="num">{r.items}</td><td className="num" style={{ fontWeight: 600 }}>{money(r.debit)}</td></tr>)}
|
||||
<tr><td style={TOTAL}>TOTAL</td><td></td><td></td><td></td><td className="num" style={TOTAL}>{jnTotItems}</td><td className="num" style={TOTAL}>{money(jnTot)}</td></tr>
|
||||
</tbody>
|
||||
</TableWrap>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Financial year" aside={<HeadActions name="the financial year summary" aside={`to the end of ${mLbl}`} onCsv={csv.fy} onPrint={print.fy} />}>
|
||||
<TableWrap>
|
||||
<thead><tr><th>Month</th><th className="num">Items issued</th><th className="num">Issued value</th><th className="num">Orders placed</th></tr></thead>
|
||||
<tbody>
|
||||
{R.fyRows.map((m) => <tr key={m.m}><td>{m.label}</td><td className="num">{m.items}</td><td className="num" style={{ fontWeight: 600 }}>{money(m.issued)}</td><td className="num">{money(m.orders)}</td></tr>)}
|
||||
<tr><td style={TOTAL}>FY TOTAL</td><td className="num" style={TOTAL}>{R.fyTot.items}</td><td className="num" style={TOTAL}>{money(R.fyTot.issued)}</td><td className="num" style={TOTAL}>{money(R.fyTot.orders)}</td></tr>
|
||||
</tbody>
|
||||
</TableWrap>
|
||||
</Panel>
|
||||
|
||||
{drill && (
|
||||
<Dialog title={`Issues behind ${drill.cc} — ${mLbl}`} width={820} onClose={() => setDrill(null)}
|
||||
sub={`${drill.dept} · ${plural(drillQty, "item")} · ${money(drillAmt)}`}
|
||||
foot={<>
|
||||
{drillRows.length > 0 && <button type="button" className="btn btn-secondary" style={{ marginRight: "auto" }} onClick={csvDrill}>Export CSV</button>}
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setDrill(null)}>Close</button>
|
||||
</>}>
|
||||
{drillRows.length === 0 ? <PanelEmpty>Nothing was issued against this cost centre in {mLbl}.</PanelEmpty> : (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<TableWrap>
|
||||
<thead><tr><th>Date</th><th>Staff</th><th>Item</th><th>Size</th><th className="num">Qty</th><th className="num">Unit cost</th><th className="num">Value</th></tr></thead>
|
||||
<tbody>
|
||||
{drillRows.map((r, i) => <tr key={i}><td className="tc-mono">{fmtDate(r.date)}</td><td>{r.who}</td><td>{r.item}</td><td className="tc-mono">{r.size}</td><td className="num">{r.qty}</td><td className="num">{money(r.unit)}</td><td className="num" style={{ fontWeight: 600 }}>{money(r.amt)}</td></tr>)}
|
||||
<tr><td style={TOTAL}>TOTAL</td><td></td><td></td><td></td><td className="num" style={TOTAL}>{drillQty}</td><td></td><td className="num" style={TOTAL}>{money(drillAmt)}</td></tr>
|
||||
</tbody>
|
||||
</TableWrap>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
import { Figures, Panel } from "@/components/portal";
|
||||
import { fmtDate, money, signedInt, signedMoney } from "@/lib/compute";
|
||||
import { HeadActions, PanelEmpty, TableWrap, TOTAL, plural } from "./bits";
|
||||
import type { ReportData } from "./useReportData";
|
||||
|
||||
export default function StockTab({ d }: { d: ReportData }) {
|
||||
const { R, mLbl, csv, print } = d;
|
||||
return (
|
||||
<div className="tc-rep-stack">
|
||||
<Panel title="Valuation" flag={R.negSizes > 0}
|
||||
aside={<HeadActions name="the stock valuation" aside={R.negSizes > 0 ? `${plural(R.negSizes, "size")} negative on hand` : "today, at catalogue cost"} onCsv={csv.valuation} onPrint={print.valuation} />}
|
||||
foot={R.negSizes > 0 ? <span className="tc-meta-line" style={{ color: "var(--color-accent-700)", fontWeight: 600 }}><span className="tc-mark" aria-hidden="true" />Negative sizes count as 0</span> : undefined}>
|
||||
{R.valRows.length === 0 ? <PanelEmpty>Nothing on hand.</PanelEmpty> : (
|
||||
<TableWrap>
|
||||
<thead><tr><th>Item</th><th>SKU</th><th>Supplier</th><th className="num">Units on hand</th><th className="num">Unit cost</th><th className="num">Value</th></tr></thead>
|
||||
<tbody>
|
||||
{R.valRows.map((r, i) => <tr key={i}><td>{r.item}</td><td className="tc-mono">{r.sku}</td><td>{r.supplier}</td><td className="num">{r.units}</td><td className="num">{money(r.cost)}</td><td className="num" style={{ fontWeight: 600 }}>{money(r.val)}</td></tr>)}
|
||||
<tr><td style={TOTAL}>TOTAL</td><td></td><td></td><td className="num" style={TOTAL}>{R.valTotUnits}</td><td></td><td className="num" style={TOTAL}>{money(R.valTot)}</td></tr>
|
||||
</tbody>
|
||||
</TableWrap>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Shrinkage" aside={<HeadActions name="shrinkage" aside={`FY to the end of ${mLbl}`} onCsv={csv.shrinkage} onPrint={print.shrinkage} />}>
|
||||
<div style={{ padding: 16 }}>
|
||||
<Figures items={[
|
||||
{ value: R.shRows.length, label: "Stocktakes this FY" },
|
||||
{ value: signedInt(R.shU), label: "Net variance, units", flag: R.shV < 0 },
|
||||
{ value: signedMoney(R.shV), label: "Net value", flag: R.shV < 0 },
|
||||
]} />
|
||||
</div>
|
||||
{R.shRows.length === 0 ? <PanelEmpty>No stocktakes filed this financial year.</PanelEmpty> : (
|
||||
<TableWrap>
|
||||
<thead><tr><th>Date</th><th>Counted by</th><th className="num">Lines counted</th><th className="num">Variances</th><th className="num">Net units</th><th className="num">Net value</th></tr></thead>
|
||||
<tbody>{R.shRows.map((r, i) => <tr key={i}><td className="tc-mono">{fmtDate(r.date)}</td><td>{r.by}</td><td className="num">{r.counted}</td><td className="num">{r.variances}</td><td className="num">{signedInt(r.net)}</td><td className="num" style={{ fontWeight: 600 }}>{signedMoney(r.netVal)}</td></tr>)}</tbody>
|
||||
</TableWrap>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Top stock" aside={<HeadActions name="top stock" aside={mLbl} onCsv={csv.topStock} onPrint={print.topStock} />}>
|
||||
{R.topRows.length === 0 ? <PanelEmpty>Nothing issued in {mLbl}.</PanelEmpty> : (
|
||||
<TableWrap>
|
||||
<thead><tr><th>#</th><th>Item</th><th>Supplier</th><th className="num">Qty (month)</th><th className="num">Value (month)</th><th className="num">Share</th><th className="num">Qty (FY)</th></tr></thead>
|
||||
<tbody>{R.topRows.map((r) => <tr key={r.n}><td className="tc-mono" style={{ color: "var(--color-neutral-700)" }}>{r.n}</td><td>{r.item}</td><td>{r.supplier}</td><td className="num" style={{ fontWeight: 600 }}>{r.qty}</td><td className="num">{money(r.val)}</td><td className="num">{r.share}</td><td className="num">{r.fyQty}</td></tr>)}</tbody>
|
||||
</TableWrap>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Supplier spend" aside={<HeadActions name="supplier spend" aside={mLbl} onCsv={csv.suppliers} onPrint={print.suppliers} />}>
|
||||
{R.supRows.length === 0 ? <PanelEmpty>No supplier orders placed in {mLbl}.</PanelEmpty> : (
|
||||
<TableWrap>
|
||||
<thead><tr><th>Supplier</th><th className="num">Orders</th><th className="num">Value</th><th>Invoices</th></tr></thead>
|
||||
<tbody>{R.supRows.map((r) => <tr key={r.name}><td>{r.name}</td><td className="num">{r.n}</td><td className="num" style={{ fontWeight: 600 }}>{money(r.amt)}</td><td className="tc-mono">{r.invoices}</td></tr>)}</tbody>
|
||||
</TableWrap>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
/* Small pieces every Reports tab shares: the CSV / Print pair on a panel head, a table frame, and
|
||||
* an empty line that sits inside a panel's padding. */
|
||||
import { Empty } from "@/components/ui";
|
||||
|
||||
const mini: React.CSSProperties = { minHeight: 0, padding: 0 };
|
||||
|
||||
export function HeadActions({ name, aside, onCsv, onPrint, csvText = "CSV", csvSecondary }: {
|
||||
name: string; aside?: React.ReactNode; onCsv?: () => void; onPrint?: () => void; csvText?: string; csvSecondary?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="tc-rep-headacts">
|
||||
{aside && <span>{aside}</span>}
|
||||
{onCsv && (csvSecondary
|
||||
? <button type="button" className="btn btn-secondary" onClick={onCsv}>{csvText}</button>
|
||||
: <button type="button" className="btn btn-ghost" style={mini} onClick={onCsv} aria-label={`Download ${name} as CSV`}>{csvText}</button>)}
|
||||
{onPrint && <button type="button" className="btn btn-ghost" style={mini} onClick={onPrint} aria-label={`Print ${name}`}>Print</button>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableWrap({ children }: { children: React.ReactNode }) {
|
||||
return <div className="table-wrap"><table className="tc-table">{children}</table></div>;
|
||||
}
|
||||
|
||||
export function PanelEmpty({ children }: { children: React.ReactNode }) {
|
||||
return <div style={{ padding: "0 16px" }}><Empty pad={4}>{children}</Empty></div>;
|
||||
}
|
||||
|
||||
export const plural = (n: number, one: string, many = one + "s") => `${n} ${n === 1 ? one : many}`;
|
||||
|
||||
export function shortDate(iso: string) {
|
||||
if (!iso || iso.length < 10) return iso || "—";
|
||||
return new Date(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10)).toLocaleDateString("en-AU", { day: "numeric", month: "short" });
|
||||
}
|
||||
|
||||
export const TOTAL: React.CSSProperties = { fontWeight: 800 };
|
||||
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
/* Every figure on Reports, computed once per snapshot and month. The computation is today's report
|
||||
* moved here unchanged; the redesign only regroups where each table is shown. Two additions sit at
|
||||
* the end of R: the number of placed orders (Spend figures) and the distinct people per staff
|
||||
* group (By staff group). */
|
||||
import { useMemo } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
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";
|
||||
|
||||
export function useReportData(month: string) {
|
||||
const { s } = useSnap();
|
||||
const { L, byId, staffById } = useDerived();
|
||||
|
||||
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);
|
||||
// Redesign additions: orders placed this month, and distinct people issued per staff group.
|
||||
const ordCount = mOrders.length;
|
||||
const groupPeopleSets: Record<string, Set<string>> = {};
|
||||
for (const i of mIssues) { const g = staffById[i.staffId]?.group || "Unknown"; (groupPeopleSets[g] = groupPeopleSets[g] || new Set()).add(i.staffId); }
|
||||
const groupRowsP = groupRows.map((g) => ({ ...g, people: groupPeopleSets[g.g]?.size || 0 }));
|
||||
return { repMonths, ccRows, ccLines, totAmt, totPrev, totItems, ordSpend, ordCount, groupRows: groupRowsP, 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 C = (t: string, r = false): Col => ({ t, r });
|
||||
|
||||
const ccTable = () => 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), ""]]);
|
||||
const staffTable = () => tbl([C("Staff"), C("CC"), C("Items", true), C("Value", true)], R.staffRows.map((r) => [r.who, r.cc, r.items, money(r.amt)]));
|
||||
const fyTable = () => 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)]]);
|
||||
const ccCsvRows = () => 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)]]);
|
||||
const staffCsvRows = () => csvOf(["Staff", "Cost Centre", "Items", "Amount"], R.staffRows.map((r) => [r.who, r.cc, r.items, r.amt.toFixed(2)]));
|
||||
const fyCsvRows = () => 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)]]);
|
||||
|
||||
const csv = {
|
||||
/* Today's Overview CSV, whole: the Spend head's Export CSV. */
|
||||
overview: () => {
|
||||
let out = `ThreadCount monthly report,${month},${csvEsc(s.settings.facility)}\n\n` + ccCsvRows();
|
||||
out += "\n" + csvOf(["Staff Group", "Items", "Amount"], R.groupRows.map((g) => [g.g, g.items, g.amt.toFixed(2)]));
|
||||
out += "\n" + staffCsvRows();
|
||||
out += "\n" + csvOf(["Supplier", "Orders", "Amount"], R.supRows.map((r) => [r.name, r.n, r.amt.toFixed(2)]));
|
||||
out += "\n" + fyCsvRows();
|
||||
downloadCsv(`threadcount-report-${month}.csv`, out);
|
||||
},
|
||||
costCentres: () => downloadCsv(`threadcount-cost-centres-${month}.csv`, `Issued value by cost centre,${month},${csvEsc(s.settings.facility)}\n\n` + ccCsvRows()),
|
||||
staff: () => downloadCsv(`threadcount-staff-${month}.csv`, `Issued value by staff member,${month},${csvEsc(s.settings.facility)}\n\n` + staffCsvRows()),
|
||||
fy: () => downloadCsv(`threadcount-financial-year-${month}.csv`, `Financial year to the end of,${month},${csvEsc(s.settings.facility)}\n\n` + fyCsvRows()),
|
||||
journal: () => 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)]])),
|
||||
valuation: () => 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)]))),
|
||||
topStock: () => 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]))),
|
||||
preloved: () => 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 print = {
|
||||
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: ccTable() },
|
||||
{ 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: staffTable() },
|
||||
{ h: "Financial year", html: fyTable() },
|
||||
]),
|
||||
costCentres: () => printDoc(`Cost centre report — ${mLbl}`, meta, [{ h: "Issued value by cost centre", html: ccTable() }]),
|
||||
staff: () => printDoc(`Issued value by staff member — ${mLbl}`, meta, [{ h: "By staff member", html: staffTable() }]),
|
||||
fy: () => printDoc(`Financial year — to the end of ${mLbl}`, meta, [{ h: "Financial year", html: fyTable() }]),
|
||||
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)]]) }]),
|
||||
topStock: () => 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])) }]),
|
||||
preloved: () => 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);
|
||||
}
|
||||
|
||||
return { R, month, mLbl, meta, jnTotItems, jnTot, csv, print, printEomPack };
|
||||
}
|
||||
|
||||
export type ReportData = ReturnType<typeof useReportData>;
|
||||
Reference in New Issue
Block a user