a1641fefc5
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).
269 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|