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 e2d6d42 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
"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} isn’t a garment ThreadCount knows. Bind it to a size first — you can type it in on the garment’s page.`
|
||||
: placedAt ? `${code} is on ${placedAt}, not this shelf.`
|
||||
: `${code} isn’t in this count. It hasn’t 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 won’t 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user