"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>({}); // 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); const [live, setLive] = useState(false); const [log, setLog] = useState([]); const [manual, setManual] = useState(false); const [err, setErr] = useState(""); const [loaded, setLoaded] = useState(false); const listRef = useRef(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 ( <> ); } return ( <> setErr("")} /> {cur && ( { setScan("live"); setLive(true); }} />}>
{variantName(byId[cur.itemId], cur.size)}
{[cur.code || (cur.item.sku ? `SKU ${cur.item.sku}` : "No barcode bound"), cur.where].filter(Boolean).join(" · ")}
)} setScan("single")} /> cur && bump(cur.key, -1)} disabled={!cur || (counted[cur.key] ?? 0) <= 0} />
{lines.map((l) => { const n = counted[l.key] ?? 0; const on = !!cur && l.key === cur.key; return ( 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={ {/* 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}/{l.expected} } /> ); })}
{manual && cur ? (
Counted for {variantName(byId[cur.itemId], cur.size)}
{/* 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. */} setCounted((c) => ({ ...c, [cur.key]: Math.max(0, parseInt(e.target.value || "0", 10) || 0) }))} />
) : ( )}
router.push(`/m/count/${locationId}/variance`)} /> {scan && ( setLive((v) => !v)} log={log} onHit={(raw) => { onCode(raw); if (scan === "single") setScan(null); }} onClose={() => { setScan(null); setLive(false); }} figure={scan === "live" && cur ? (
{variantName(byId[cur.itemId], cur.size)}
{counted[cur.key] ?? 0} {cur.expected} {total} / {expectedAll} on this shelf
) : undefined} /> )} ); }