Files
threadcount-community/app/m/(app)/count/[id]/page.tsx
T
ThreadCount 96d5c10537 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 49da3a4 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 11:38:24 +10:00

221 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/* Counting — the screen the app exists for. Scan a garment, the active line goes up by one.
Expected quantities stay visible throughout: this is a sighted count, not a blind one.
The tally lives in localStorage, so backgrounding the app mid-shelf loses nothing. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { bcBound, locMap, locSubtree, locUnder, onhand, touched, UNPLACED, variantName } from "@/lib/compute";
import MScan from "@/components/MScan";
import { scanReject } from "@/lib/feedback";
import { track } from "@/lib/analytics";
import { useKeepAwake } from "@/lib/wakelock";
import { INK, MAction, MBody, MEmpty, MError, MFigures, MInkLink, MPanel, MRow, MRule, MSection, MSplit, MTop, ON_DARK, inputStyle } from "@/components/m";
import { readCount, writeCount } from "@/lib/opencount";
export default function MCounting() {
const { s } = useSnap();
const { L, byId, variants } = useDerived();
const router = useRouter();
const locationId = String(useParams().id || "");
const locs = useMemo(() => locMap(s), [s]);
const loc = locs[locationId];
const locName = locationId === UNPLACED ? "Not on a shelf" : loc?.name || "Location";
// The lines on this shelf, in catalogue order.
//
// Being placed on the shelf is enough to be countable: a size placed from the desktop but never
// stocked has no history at all, and filtering it out meant the six of them you have just found
// on the shelf could not be counted in from the count that found them. The unplaced bucket still
// needs the history test, or it would be the whole catalogue.
//
// The variance screen repeats this test verbatim, and the two have to keep listing the same
// lines: anything countable here but missing there is counted on the phone and then dropped at
// commit, with the tally cleared behind it and nothing said.
const lines = useMemo(() => {
const sub = locationId === UNPLACED ? null : locSubtree(s, locationId);
return variants
// A bound barcode counts as much as stock history does. Somebody stood at the counter with
// the garment in one hand and scanned its label onto that size — that is a stronger statement
// that the size physically exists than a stock figure, which on a room being set up is
// precisely what nobody has yet. Without this the first count after building a catalogue can
// reach nothing at all: every size is unplaced and untouched, so the list is empty and every
// scan is refused as belonging somewhere else.
.filter((v) => (sub ? sub.has(s.placed[v.key] || "") : !s.placed[v.key] && (touched(s, L, v.key) || !!bcBound(s, v.item, v.si))))
.map((v) => ({ ...v, expected: onhand(s, L, v.key), code: bcBound(s, v.item, v.si), where: locUnder(locs, s.placed[v.key], locationId) }));
}, [s, L, variants, locationId, locs]);
const [counted, setCounted] = useState<Record<string, number>>({});
// The line being counted is held by its variant key, never by its position in `lines`. The list
// is rebuilt on every live refresh, and a size sorting earlier in the catalogue being inserted
// ahead of it would leave an index pointing at the neighbouring garment: the next Undo would then
// take one off a line that was counted correctly and leave the double-scan where it was.
const [activeKey, setActiveKey] = useState("");
const [scan, setScan] = useState<null | "single" | "live">(null);
const [live, setLive] = useState(false);
const [log, setLog] = useState<string[]>([]);
const [manual, setManual] = useState(false);
const [err, setErr] = useState("");
const [loaded, setLoaded] = useState(false);
const listRef = useRef<HTMLDivElement | null>(null);
// Restore this person's open count of this shelf. The tally is keyed on the signed-in user as
// well as the location: the phone is shared, and resuming somebody else's abandoned count under
// your own name is worse than starting again.
//
// It reads once per shelf and deliberately does not re-run on `lines`. The list is rebuilt on
// every live refresh, and rebuilding the tally from it dropped any key that had just left this
// shelf — the coordinator placing a size from the desktop while the trolley is being counted —
// which the write below then made permanent. The garments the counter had already found went
// with it, silently. Counts are held by key whether or not the key is still listed here.
const me = s.session.userId;
useEffect(() => {
setCounted(readCount(me, locationId)?.n ?? {});
setLoaded(true);
}, [me, locationId]);
useEffect(() => {
if (!loaded) return;
writeCount(me, locationId, counted);
}, [counted, me, locationId, loaded]);
useKeepAwake(true);
const total = lines.reduce((t, l) => t + (counted[l.key] ?? 0), 0);
const expectedAll = lines.reduce((t, l) => t + l.expected, 0);
const cur = lines.find((l) => l.key === activeKey) || lines[0];
const bump = useCallback((k: string, by: number) => {
setCounted((c) => ({ ...c, [k]: Math.max(0, (c[k] ?? 0) + by) }));
}, []);
/** A scanned code lands on its own line, whichever line was active — the barcode is the truth. */
const onCode = useCallback((raw: string) => {
const code = raw.trim();
const hit = s.barcodes[code];
const ix = hit ? lines.findIndex((l) => l.key === hit) : lines.findIndex((l) => l.code === code);
if (ix < 0) {
scanReject();
const known = Object.prototype.hasOwnProperty.call(s.barcodes, code);
// Never the barcode itself — only whether ThreadCount knew it. "unknown" in volume means
// labels are being printed outside the catalogue.
track("scan_miss", { kind: known ? "wrong_shelf" : "unknown" });
/* "Somewhere else" is only true when it IS somewhere. A code bound to a size that has never
been placed and never been stocked is on no shelf at all, and telling somebody to go and
look for it elsewhere sends them hunting for a garment nothing has ever recorded. Say which
of the two it is, and name the shelf when there is one to name. */
const placedAt = hit ? locs[s.placed[hit] || ""]?.name : "";
setErr(!known ? `${code} isnt a garment ThreadCount knows. Bind it to a size first — you can type it in on the garments page.`
: placedAt ? `${code} is on ${placedAt}, not this shelf.`
: `${code} isnt in this count. It hasnt been placed on a shelf, so it sits under “Not on a shelf”.`);
setLog((g) => [`${code} — not on this shelf`, ...g]);
return;
}
setActiveKey(lines[ix].key);
bump(lines[ix].key, 1);
setErr("");
setLog((g) => [`${variantName(byId[lines[ix].itemId], lines[ix].size)}`, ...g].slice(0, 8));
}, [s.barcodes, lines, bump, byId]);
if (loaded && !lines.length) {
return (
<>
<MTop title={locName} back />
<MRule />
<MBody><MEmpty title="Nothing on this shelf" sub="No garment has been placed here yet. Place sizes against a location from Inventory on the desktop, then come back." /></MBody>
</>
);
}
return (
<>
<MTop title={locName} right={`${total} / ${expectedAll}`} back />
<MRule n={total} of={expectedAll} />
<MError msg={err} onDismiss={() => setErr("")} />
{cur && (
<MPanel kicker="Now counting" kickerRight={<MInkLink label="Hands-free" onClick={() => { setScan("live"); setLive(true); }} />}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 26, letterSpacing: "-0.02em", lineHeight: 1.1 }}>
{variantName(byId[cur.itemId], cur.size)}
</div>
<div style={{ fontSize: 13, color: ON_DARK, marginTop: 6 }}>
{[cur.code || (cur.item.sku ? `SKU ${cur.item.sku}` : "No barcode bound"), cur.where].filter(Boolean).join(" · ")}
</div>
<MFigures counted={counted[cur.key] ?? 0} expected={cur.expected} />
</MPanel>
)}
<MSplit>
<MAction label="Scan" flex={2} glyph="scan" onClick={() => setScan("single")} />
<MAction label="Undo" flex={1} tone="grey" onClick={() => cur && bump(cur.key, -1)} disabled={!cur || (counted[cur.key] ?? 0) <= 0} />
</MSplit>
<MBody>
<div ref={listRef}>
<MSection label="Lines" right="Counted / expected" />
{lines.map((l) => {
const n = counted[l.key] ?? 0;
const on = !!cur && l.key === cur.key;
return (
<MRow key={l.key} onClick={() => setActiveKey(l.key)} attention={on}
mark={on ? "accent" : n === l.expected ? "ink" : "mute"}
title={`${variantName(byId[l.itemId], l.size)}`}
sub={[l.code || (l.item.sku ? `SKU ${l.item.sku}` : "No barcode bound"), l.where].filter(Boolean).join(" · ")}
right={
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, fontVariantNumeric: "tabular-nums" }}>
{/* The expected figure is the whole point of the row, so it is readable ink,
not the near-invisible neutral-400 it used to be drawn in. */}
{n}<span style={{ color: "var(--color-neutral-700)" }}>/{l.expected}</span>
</span>
} />
);
})}
</div>
<div style={{ padding: 16 }}>
{manual && cur ? (
<div style={{ border: "2px solid " + INK, background: "#fff", padding: 16 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>Counted for {variantName(byId[cur.itemId], cur.size)}</div>
{/* Keyed on the line so the box is rebuilt when the counter taps a different one. An
uncontrolled input keeps its first value, so it went on showing the figure typed
for the previous line under the new line's heading — read as "counted at 7", the
new line was then committed at 0 and the gap blamed on the shelf. */}
<input key={cur.key} type="number" inputMode="numeric" min={0} defaultValue={counted[cur.key] ?? 0} autoFocus style={{ ...inputStyle, marginTop: 8 }}
onChange={(e) => setCounted((c) => ({ ...c, [cur.key]: Math.max(0, parseInt(e.target.value || "0", 10) || 0) }))} />
<button onClick={() => setManual(false)} style={{ marginTop: 12, minHeight: 44, width: "100%", border: "2px solid " + INK, background: "transparent", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>Done</button>
</div>
) : (
<button onClick={() => setManual(true)} style={{ background: "none", border: 0, padding: "8px 0", color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>
Type a count instead for a label that wont scan
</button>
)}
</div>
</MBody>
<MAction label="Finish count" glyph="none" onClick={() => router.push(`/m/count/${locationId}/variance`)} />
{scan && (
<MScan
title="Scan a garment"
live={scan === "live"}
running={live}
onToggle={() => setLive((v) => !v)}
log={log}
onHit={(raw) => { onCode(raw); if (scan === "single") setScan(null); }}
onClose={() => { setScan(null); setLive(false); }}
figure={scan === "live" && cur ? (
<MPanel pad={14}>
<div style={{ fontSize: 13, color: ON_DARK }}>{variantName(byId[cur.itemId], cur.size)}</div>
<div style={{ display: "flex", alignItems: "baseline", gap: 14, marginTop: 4 }}>
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 40, lineHeight: 1, fontVariantNumeric: "tabular-nums" }}>{counted[cur.key] ?? 0}</span>
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 22, color: "var(--color-neutral-300)", fontVariantNumeric: "tabular-nums" }}>{cur.expected}</span>
<span style={{ marginLeft: "auto", fontSize: 12, color: ON_DARK }}>{total} / {expectedAll} on this shelf</span>
</div>
</MPanel>
) : undefined}
/>
)}
</>
);
}