Files
threadcount-community/app/app/stocktake/page.tsx
T
ThreadCount a1641fefc5 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 a9cb141 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 12:38:06 +10:00

269 lines
21 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { PageHead, Empty, InvTabs, KpiStrip, Notice, Seg } from "@/components/ui";
import { BindDialog } from "@/components/dialogs";
import Camera from "@/components/Camera";
import { ALL_GROUPS, bcBound, bcFor, bcParse, csvOf, fmtDate, formatInZone, inBucket, key, label, money, onhand, signedInt, signedMoney, plOf, csvEsc } from "@/lib/compute";
import { downloadCsv, esc, openPrintWindow } from "@/lib/print";
/* An in-progress count belongs to the person doing it, not to the browser. On a shared linen-room
desktop the old fixed key handed whoever signed in next a half-finished tally with nothing to say
whose it was, and they committed it under their own name. Scoping the key to the user id keeps
two counters on one machine apart; the saved-at stamp lets the screen say how old a restored
tally is, because a count from last Tuesday is not one to carry on with. */
const countsKey = (userId: string) => `threadcount-counts:${userId}`;
type Saved = { counts: Record<string, string>; savedAt: string };
const VIEWS = ["All", "Uncounted"] as const;
const MODES = ["Normal", "Blind"] as const;
const POOLS = ["Shelf", "Pre-loved"] as const;
/* The same four the phone offers on the variance screen (app/m/(app)/count/[id]/variance). They are
deliberately the same words: a variance filed at the counter and one filed on the floor end up in
the same shrinkage report, and a fifth wording here would fragment it. */
const REASONS = ["At laundry", "Condemned", "Missing", "Other"];
export default function StocktakePage() {
const { s, mutate } = useSnap();
const { L, byId, variants } = useDerived();
const [counts, setCountsRaw] = useState<Record<string, string>>({});
const [reason, setReason] = useState<Record<string, string>>({});
const [savedAt, setSavedAt] = useState("");
const [loaded, setLoaded] = useState(false);
const [scan, setScan] = useState("");
const [msg, setMsg] = useState("");
const [q, setQ] = useState("");
const [group, setGroup] = useState("All groups");
const [view, setView] = useState<(typeof VIEWS)[number]>("All");
const [mode, setMode] = useState<(typeof MODES)[number]>("Normal");
const [cam, setCam] = useState(false);
useEffect(() => { const h = () => { setCamMsg(""); setCam(true); }; window.addEventListener("tc-scan", h); return () => window.removeEventListener("tc-scan", h); }, []);
const [camMsg, setCamMsg] = useState("");
const [bind, setBind] = useState("");
const [expand, setExpand] = useState<string | null>(null);
const [limit, setLimit] = useState(60);
const [busy, setBusy] = useState(false);
const [pool, setPool] = useState<(typeof POOLS)[number]>("Shelf");
const blind = mode === "Blind";
const plMode = pool === "Pre-loved";
// Pre-loved counts live under a "pl:" prefix so a shelf take and a pool take can be in progress together.
const kOf = (k: string) => (plMode ? "pl:" + k : k);
const sysOf = (k: string) => (plMode ? plOf(s, k) : onhand(s, L, k));
// Counts persist across reloads until applied or cleared.
const KEY = countsKey(s.session.userId);
useEffect(() => {
try {
const raw = JSON.parse(localStorage.getItem(KEY) || "{}") as Partial<Saved>;
if (raw && typeof raw.counts === "object" && raw.counts) { setCountsRaw(raw.counts); setSavedAt(typeof raw.savedAt === "string" ? raw.savedAt : ""); }
} catch { /* ignore */ }
setLoaded(true);
}, [KEY]);
const setCounts = (c: Record<string, string>) => {
setCountsRaw(c);
const at = new Date().toISOString();
setSavedAt(at);
try { localStorage.setItem(KEY, JSON.stringify({ counts: c, savedAt: at } satisfies Saved)); } catch { /* ignore */ }
};
const has = (k: string) => counts[k] !== undefined && counts[k] !== "";
function countPlus(itemId: string, si: number) {
const k = kOf(key(itemId, si));
const n = (parseInt(counts[k] || "0", 10) || 0) + 1;
setCounts({ ...counts, [k]: String(n) });
const it = byId[itemId];
return `${label(it)} ${it?.sizes[si]}${n}`;
}
function handleScan(raw: string) {
const p = bcParse(s, raw);
if (!p) { setScan(""); setBind(raw.trim()); return; }
setMsg(countPlus(p.itemId, p.si)); setScan("");
}
function camHit(raw: string) {
const p = bcParse(s, raw);
if (!p) { setCam(false); setBind(raw.trim()); return; }
setCamMsg("Counted " + countPlus(p.itemId, p.si));
}
const tq = q.trim().toLowerCase();
const scopeAll = useMemo(() => variants.filter((v) => inBucket(v.item, group)), [variants, group]);
const match = useMemo(() => scopeAll.filter((v) => (view !== "Uncounted" || !has(kOf(v.key))) && (!tq || v.item.item.toLowerCase().includes(tq) || v.item.sku.toLowerCase().includes(tq) || v.size.toLowerCase() === tq || bcFor(s, v.item, v.si).includes(tq))),
// eslint-disable-next-line react-hooks/exhaustive-deps
[scopeAll, view, tq, counts, s, plMode]);
// A gap this big or bigger has to say why. The server refuses the whole count otherwise
// (lib/ops.ts, stocktake.apply), so without a chooser on this screen a desktop count with a real
// discrepancy could never be filed at all — the only ways out were to abandon it or type a
// figure nobody had counted.
const gate = Math.max(1, s.settings.varianceReason);
let counted = 0, variances = 0, netVal = 0;
const bigGaps: string[] = [];
for (const v of variants) if (has(kOf(v.key))) { counted++; const diff = (parseInt(counts[kOf(v.key)], 10) || 0) - sysOf(v.key); if (diff !== 0) { variances++; netVal += diff * (plMode ? 0 : v.item.cost); if (Math.abs(diff) >= gate) bigGaps.push(kOf(v.key)); } }
const needsReason = bigGaps.filter((k) => !reason[k]);
const scopeCounted = scopeAll.filter((v) => has(kOf(v.key))).length;
const pct = Math.round((scopeCounted / Math.max(scopeAll.length, 1)) * 100);
// A line that still owes a reason has to be reachable whatever the filter says. "Uncounted" hides
// every counted line, and the row limit hides the tail, so without this Apply could be blocked by
// a gap the screen was refusing to show. Those rows are forced to the top instead.
const needSet = new Set(needsReason);
const forced = needSet.size ? variants.filter((v) => needSet.has(kOf(v.key)) && !match.includes(v)) : [];
const rows = [...forced, ...[...match].sort((a, b) => (has(kOf(b.key)) ? 1 : 0) - (has(kOf(a.key)) ? 1 : 0))];
async function apply() {
if (counted === 0 || busy) return;
if (needsReason.length) { setMsg(`A gap of ${gate} or more needs a reason — ${needsReason.length} line${needsReason.length === 1 ? "" : "s"} still to go.`); return; }
setBusy(true);
const lines = variants.filter((v) => has(kOf(v.key))).map((v) => ({ itemId: v.itemId, si: v.si, counted: parseInt(counts[kOf(v.key)], 10) || 0, reason: reason[kOf(v.key)] || "" }));
const r = await mutate("stocktake.apply", { lines, mode: plMode ? "preloved" : "shelf" });
setBusy(false);
if (!r.ok) { setMsg(r.error); return; }
const kept: Record<string, string> = {}; for (const k in counts) if (plMode ? !k.startsWith("pl:") : k.startsWith("pl:")) kept[k] = counts[k];
const keptReasons: Record<string, string> = {}; for (const k in reason) if (kept[k]) keptReasons[k] = reason[k];
setCounts(kept); setReason(keptReasons);
setMsg(variances ? (plMode ? "Pre-loved pool updated and filed in stocktake history." : "Adjustments applied and filed in stocktake history.") : "Count filed in stocktake history — everything matched.");
}
function zeroFill() {
const c = { ...counts }; let n = 0;
for (const v of match) if (!has(kOf(v.key))) { c[kOf(v.key)] = "0"; n++; }
setCounts(c); setMsg(n ? `${n} uncounted line${n === 1 ? "" : "s"} in scope set to zero — review before applying.` : "Everything in scope is already counted.");
}
function printCountSheet() {
const byItem: Record<string, typeof match> = {};
for (const v of match) (byItem[v.itemId] = byItem[v.itemId] || []).push(v);
let rowsHtml = "";
for (const itemId in byItem) {
const it = byId[itemId];
rowsHtml += `<tr class="ih"><td colspan="4">${esc(label(it))}${it?.sku ? " · " + esc(it.sku) : ""}</td></tr>`;
// Only the real supplier code goes on paper — a generated id isn't on the garment, so printing
// it would put an unscannable number in front of whoever is counting.
for (const v of byItem[itemId]) rowsHtml += `<tr><td>${esc(v.size)}</td><td>${esc(bcBound(s, v.item, v.si))}</td><td class="r">${blind ? "" : sysOf(v.key)}</td><td class="box"></td></tr>`;
}
openPrintWindow("Count sheet", `<h1>ThreadCount — ${plMode ? "Pre-loved pool" : "Stocktake"} count sheet</h1><div class="meta">${esc(s.settings.facility)} · Scope: ${esc(group)}${tq ? " · filter “" + esc(q) + "”" : ""} · ${match.length} lines · Printed ${esc(fmtDate(s.today))} · Counted by ____________ ${blind ? "· BLIND COUNT" : ""}</div><table><tr><th>Size</th><th>Barcode</th><th class="r">${blind ? "" : "System"}</th><th>Counted</th></tr>${rowsHtml}</table>`, { width: 780, height: 920 });
}
function historyCsv(h: (typeof s.stocktakes)[number]) {
downloadCsv(`threadcount-stocktake-${h.date}.csv`, `Stocktake ${h.date} by ${csvEsc(h.by)}${h.mode === "preloved" ? " · pre-loved pool" : ""}\n` + csvOf(["Item", "Size", "System", "Counted", "Variance", "Unit cost", "Variance value"], h.lines.filter((l) => l.counted !== l.sys).map((l) => { const it = byId[l.itemId]; const diff = l.counted - l.sys; return [label(it), it ? String(it.sizes[l.si]) : "?", l.sys, l.counted, diff, it ? it.cost : "", it ? (diff * it.cost).toFixed(2) : ""]; })));
}
return (
<section>
<PageHead eyebrow="Inventory" title="Stock Take" below={<InvTabs active="take" />}>
<button className="btn btn-ghost" onClick={printCountSheet}>Print count sheet</button>
<button className="btn btn-ghost" onClick={() => { setCounts({}); setReason({}); setMsg(""); }}>Clear counts</button>
<button className="btn btn-primary" onClick={apply} disabled={counted === 0 || busy || !loaded || needsReason.length > 0} title={needsReason.length ? `${needsReason.length} large gap${needsReason.length === 1 ? "" : "s"} still need a reason` : undefined}>{variances === 0 ? "File count" : "Apply adjustments"}</button>
</PageHead>
{/* Where the count is up to, at a size that reads from the shelf you are standing at rather
than from a line of small print beside the buttons. A blind count drops two of these on
purpose: showing a variance would tell the counter the answer. */}
<KpiStrip items={[
{ val: counted, label: "Lines counted", note: `${scopeCounted} of ${scopeAll.length} in the scope you are filtered to` },
...(blind ? [] : [
{ val: variances, label: "Variances", flag: variances > 0, note: variances > 0 ? "the shelf disagrees with the ledger" : "every counted line matched" },
{ val: signedMoney(netVal), label: "Net value", flag: netVal !== 0, note: plMode ? "the pre-loved pool is carried at nil" : "what applying this count would move" },
]),
{ val: needsReason.length, label: "Gaps needing a reason", flag: needsReason.length > 0, note: needsReason.length > 0 ? "the count cannot be filed until each has one" : `a gap of ${gate} or more has to say why` },
]} />
<div style={{ display: "flex", gap: "var(--space-3)", alignItems: "center", margin: "var(--space-4) 0 var(--space-2)", flexWrap: "wrap" }}>
<input className="input" style={{ width: 280 }} aria-label="Scan a barcode to add one to its count" placeholder="Scan barcode to count +1, then Enter" value={scan} onChange={(e) => setScan(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && scan.trim()) handleScan(scan); }} autoFocus />
<button className="btn btn-ghost" onClick={() => { setCamMsg(""); setCam(true); }}>Camera</button>
<input className="input" style={{ width: 180 }} aria-label="Filter the lines to count" placeholder="Filter items…" value={q} onChange={(e) => setQ(e.target.value)} />
<select className="input" style={{ width: 160 }} aria-label="Staff group to count" value={group} onChange={(e) => setGroup(e.target.value)}>{[ALL_GROUPS, ...s.settings.staffGroups].map((g) => <option key={g}>{g}</option>)}</select>
{/* Three segmented controls in a row: without a name on each group a screen reader reads six
bare words ("Shelf, Pre-loved, All, Uncounted…") with nothing to say what they switch. */}
<span role="group" aria-label="Which pool to count"><Seg opts={POOLS} value={pool} onChange={setPool} /></span>
<span role="group" aria-label="Which lines to show"><Seg opts={VIEWS} value={view} onChange={setView} /></span>
<span role="group" aria-label="Counting mode"><Seg opts={MODES} value={mode} onChange={setMode} /></span>
<button className="btn btn-ghost" onClick={zeroFill}>Zero uncounted in scope</button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)", marginBottom: "var(--space-3)" }}>
<div className="bar-track" style={{ flex: 1 }}><div className="bar-fill" style={{ width: pct + "%" }} /></div>
{/* The tile above already gives the two numbers; the bar is here to be glanced at, so it
says the one thing the numbers do not — how far along this is. */}
<span style={{ fontSize: 12, color: "var(--color-neutral-700)", flex: "none" }}>{pct}% of this scope counted</span>
</div>
{/* Whose tally this is is settled by the key it was saved under; when it was entered is not,
and a count picked up three days later is a different thing from one left ten minutes ago. */}
{loaded && counted > 0 && savedAt && <div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginBottom: "var(--space-2)" }}>Carrying on your saved tally last entry {formatInZone(savedAt, s.tz, { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" })}. Clear counts to start again.</div>}
<Notice msg={msg} />
<div className="tc-panel">
<div className="tc-panel-head">
<div>{plMode ? "Pre-loved pool" : "Shelf"} lines to count</div>
<div className="tc-panel-aside">{Math.min(limit, rows.length)} of {match.length} in scope{blind ? " · blind" : ""}</div>
</div>
<div className="table-wrap">
<table className="table">
<thead><tr><th style={{ textAlign: "left" }}>Item</th><th style={{ textAlign: "left" }}>Size</th><th style={{ textAlign: "right" }}>{blind ? "" : "System"}</th><th style={{ textAlign: "right", width: 110 }}>Counted</th><th style={{ textAlign: "right" }}>{blind ? "" : "Variance"}</th><th style={{ textAlign: "left", width: 170 }}>Reason</th></tr></thead>
<tbody>
{rows.slice(0, limit).map((v) => {
const ck = kOf(v.key); const sys = sysOf(v.key); const h = has(ck); const varr = h ? (parseInt(counts[ck], 10) || 0) - sys : 0;
const big = h && Math.abs(varr) >= gate;
const name = `${label(v.item)} size ${v.size}`;
return (
<tr key={v.key}>
<td style={{ fontWeight: 600 }}>{label(v.item)}</td>
<td>{v.size}</td>
<td style={{ textAlign: "right" }}>{blind ? "" : sys}</td>
<td style={{ textAlign: "right" }}><input className="input" style={{ width: 70, textAlign: "right", minHeight: 28, padding: "2px 8px" }} inputMode="numeric" aria-label={`Counted — ${name}`} value={h ? counts[ck] : ""} onChange={(e) => setCounts({ ...counts, [ck]: e.target.value.replace(/[^0-9]/g, "") })} /></td>
<td style={{ textAlign: "right", fontWeight: 700, color: !blind && h && varr !== 0 ? "var(--color-accent-700)" : "var(--color-neutral-700)" }}>{blind ? (h ? "✓" : "") : h ? signedInt(varr) : "—"}</td>
{/* Only the lines that need one. A blind count still shows the chooser — the system
figure stays hidden, but a line that cannot be filed without an explanation has
to say so while the counter is still standing at the shelf. */}
<td>{big && (<>
{/* The red border alone would be one more red on a screen that already has the
accent everywhere, so an unanswered row also carries the mark and says
"Needs a reason" in the box itself. */}
{!reason[ck] && <span className="tc-mark" aria-hidden="true" />}
<select className="input" style={{ minHeight: 28, padding: "2px 6px", fontSize: 12, borderColor: reason[ck] ? undefined : "var(--color-accent-600)" }} aria-label={`Reason for the gap on ${name}`} value={reason[ck] || ""} onChange={(e) => setReason({ ...reason, [ck]: e.target.value })}>
<option value="">Needs a reason</option>
{REASONS.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</>)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{variants.length === 0 && <div className="tc-panel-body"><Empty>No catalogue items to count yet.</Empty></div>}
{rows.length > limit && <div className="tc-panel-foot"><button className="btn btn-secondary" onClick={() => setLimit(100000)}>Show all {match.length} variants</button></div>}
<div className="tc-panel-foot" style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>Blind hides the system figure while you count.</div>
</div>
<div className="tc-panel" style={{ marginTop: "var(--space-8)" }}>
<div className="tc-panel-head">
<div>Stocktake history</div>
{s.stocktakes.length > 0 && <div className="tc-panel-aside">{s.stocktakes.length} filed</div>}
</div>
{s.stocktakes.length === 0 && <div className="tc-panel-body"><Empty pad={4}>No stocktakes filed yet.</Empty></div>}
{s.stocktakes.map((h) => {
const net = h.lines.reduce((t, l) => t + (l.counted - l.sys), 0);
const nv = h.mode === "preloved" ? 0 : h.lines.reduce((t, l) => t + (l.counted - l.sys) * (byId[l.itemId]?.cost || 0), 0);
const open = expand === h.id;
return (
<div key={h.id} style={{ borderBottom: "1px solid var(--color-divider)" }}>
{/* The row can't be one big button — it carries a CSV button of its own, and a button
inside a button is neither valid nor operable. The disclosure is its own control, so
the keyboard can open a count without reaching for a mouse. */}
<div className="tc-row row-hover" onClick={() => setExpand(open ? null : h.id)} style={{ cursor: "pointer", flexWrap: "wrap", borderBottom: "none" }}>
<div className="tc-row-name" style={{ minWidth: 100 }}>{fmtDate(h.date)}</div>
<div className="tc-row-main" style={{ fontSize: 13, color: "var(--color-neutral-800)" }}>Counted by {h.by}{h.mode === "preloved" ? " · pre-loved pool" : ""} · {h.counted} lines counted · <b>{h.variances}</b> variance(s) · net {signedInt(net)} ({signedMoney(nv)})</div>
<button className="btn btn-ghost" style={{ minHeight: 26, padding: "2px 8px" }} aria-label={`Download the ${fmtDate(h.date)} count as CSV`} onClick={(e) => { e.stopPropagation(); historyCsv(h); }}>CSV</button>
<button type="button" className="btn btn-ghost btn-icon" aria-expanded={open} aria-label={`${open ? "Hide" : "Show"} the variances from the ${fmtDate(h.date)} count`} style={{ fontSize: 12, color: "var(--color-neutral-600)" }} onClick={(e) => { e.stopPropagation(); setExpand(open ? null : h.id); }}>{open ? "▾" : "▸"}</button>
</div>
{open && (
<div style={{ padding: "0 var(--space-4) var(--space-3)" }}>
{h.variances === 0 && <Empty pad={2}>No variances every counted line matched.</Empty>}
{h.lines.filter((l) => l.counted !== l.sys).map((l, i) => { const diff = l.counted - l.sys; return (
<div key={i} style={{ display: "flex", justifyContent: "space-between", gap: "var(--space-3)", padding: "var(--space-1) 0", fontSize: 13, borderBottom: "1px solid var(--color-neutral-200)" }}>
<div>{label(byId[l.itemId])} · size {byId[l.itemId]?.sizes[l.si] ?? "?"}</div>
<div>system {l.sys} counted {l.counted} · <b style={{ color: "var(--color-accent-700)" }}>{signedInt(diff)}</b> ({money(Math.abs(diff) * (byId[l.itemId]?.cost || 0))})</div>
</div>
); })}
</div>
)}
</div>
);
})}
</div>
{cam && <Camera onHit={camHit} message={camMsg} onClose={() => setCam(false)} />}
{bind && <BindDialog code={bind} onClose={() => setBind("")} onBound={(itemId, si) => setMsg(countPlus(itemId, si))} />}
</section>
);
}