"use client"; import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { useDerived, useSnap } from "@/lib/client"; import { Empty, Notice } from "@/components/ui"; import { BindDialog } from "@/components/dialogs"; import Camera from "@/components/Camera"; import { Bar, Panel, Seg, SelectButton } from "@/components/portal"; import { ALL_GROUPS, bcBound, bcFor, bcParse, csvEsc, csvOf, fmtDate, formatInZone, inBucket, key, label, locMap, locPath, locSubtree, locTree, money, onhand, plOf, signedInt, signedMoney } from "@/lib/compute"; import { downloadCsv, esc, openPrintWindow } from "@/lib/print"; import { setQuery } from "./url"; /* An in-progress count belongs to the person doing it, not to the browser: the key is scoped to the user id, and the saved-at stamp says how old a restored tally is. */ const countsKey = (userId: string) => `threadcount-counts:${userId}`; type Saved = { counts: Record; 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 its variance screen, so shrinkage reports stay in one wording. */ const REASONS = ["At laundry", "Condemned", "Missing", "Other"]; export default function CountTab({ initLocation }: { initLocation: string }) { const { s, mutate } = useSnap(); const { L, byId, variants } = useDerived(); const [counts, setCountsRaw] = useState>({}); const countsRef = useRef(counts); countsRef.current = counts; const [reason, setReason] = useState>({}); 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 [loc, setLoc] = useState(s.locations.some((l) => l.id === initLocation) ? initLocation : ""); const [view, setView] = useState<(typeof VIEWS)[number]>("All"); const [mode, setMode] = useState<(typeof MODES)[number]>("Normal"); const [pool, setPool] = useState<(typeof POOLS)[number]>("Shelf"); const [cam, setCam] = useState(false); const [camMsg, setCamMsg] = useState(""); const [bind, setBind] = useState(""); const [expand, setExpand] = useState(null); const [limit, setLimit] = useState(60); const [busy, setBusy] = useState(false); 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 run together. const kOf = (k: string) => (plMode ? "pl:" + k : k); const sysOf = (k: string) => (plMode ? plOf(s, k) : onhand(s, L, k)); const KEY = countsKey(s.session.userId); useEffect(() => { try { const raw = JSON.parse(localStorage.getItem(KEY) || "{}") as Partial; 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) => { countsRef.current = c; setCountsRaw(c); const at = new Date().toISOString(); setSavedAt(at); try { localStorage.setItem(KEY, JSON.stringify({ counts: c, savedAt: at } satisfies Saved)); } catch { /* ignore */ } }; // A location scopes the count to the sizes placed at it or anywhere under it; the server refuses // a line from anywhere else, so lines outside the scope are never counted or sent. const scopeLocs = useMemo(() => (loc ? locSubtree(s, loc) : null), [s, loc]); const inLoc = (k: string) => !scopeLocs || scopeLocs.has(s.placed[k] || ""); const poolVariants = useMemo(() => variants.filter((v) => !scopeLocs || scopeLocs.has(s.placed[v.key] || "")), [variants, scopeLocs, s.placed]); const has = (k: string, from = counts) => from[k] !== undefined && from[k] !== ""; function countPlus(itemId: string, si: number) { const it = byId[itemId]; const name = `${label(it)} ${it?.sizes[si] ?? ""}`; if (!inLoc(key(itemId, si))) return `${name} isn't placed under this location — not counted.`; const cur = countsRef.current; const k = kOf(key(itemId, si)); const n = (parseInt(cur[k] || "0", 10) || 0) + 1; setCounts({ ...cur, [k]: String(n) }); return `${name} → ${n}`; } function handleScan(raw: string) { const p = bcParse(s, raw); setScan(""); if (!p) { setBind(raw.trim()); return; } setMsg(countPlus(p.itemId, p.si)); } function camHit(raw: string) { const p = bcParse(s, raw); if (!p) { setCam(false); setBind(raw.trim()); return; } setCamMsg(countPlus(p.itemId, p.si)); } // The phone SCAN button opens the camera; the desk scanner, typed outside any field, arrives as a // garment the shell has already resolved. const hitRef = useRef(countPlus); hitRef.current = countPlus; useEffect(() => { const onCam = () => { setCamMsg(""); setCam(true); }; const onGarment = (e: Event) => { const d = (e as CustomEvent<{ itemId: string; si: number }>).detail; if (d) setMsg(hitRef.current(d.itemId, d.si)); }; window.addEventListener("tc-scan", onCam); window.addEventListener("tc-scan-garment", onGarment); return () => { window.removeEventListener("tc-scan", onCam); window.removeEventListener("tc-scan-garment", onGarment); }; }, []); const tq = q.trim().toLowerCase(); const scopeAll = useMemo(() => poolVariants.filter((v) => inBucket(v.item, group)), [poolVariants, 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. const gate = Math.max(1, s.settings.varianceReason); let counted = 0, variances = 0, netVal = 0; const bigGaps: string[] = []; for (const v of poolVariants) 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 still owing a reason stays reachable whatever the filter or the row limit hides. const needSet = new Set(needsReason); const forced = needSet.size ? poolVariants.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 sent = poolVariants.filter((v) => has(kOf(v.key))); const lines = sent.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", ...(loc ? { locationId: loc } : {}) }); setBusy(false); if (!r.ok) { setMsg(r.error); return; } const done = new Set(sent.map((v) => kOf(v.key))); const kept: Record = {}; for (const k in counts) { if (done.has(k)) continue; // A whole-room count clears the pool's tally, as it always has; a location count keeps the rest. if (!loc && (plMode ? k.startsWith("pl:") : !k.startsWith("pl:"))) continue; kept[k] = counts[k]; } const keptReasons: Record = {}; 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." : "Adjustments applied and filed.") : "Count filed — 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"} set to zero.` : "Everything in scope is already counted."); } const lById = useMemo(() => locMap(s), [s]); const trail = (id: string | null | undefined) => locPath(lById, id).map((l) => l.name).join(" · "); function printCountSheet() { const byItem: Record = {}; 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 += `${esc(label(it))}${it?.sku ? " · " + esc(it.sku) : ""}`; // Only the real supplier code goes on paper; a generated id isn't on the garment. for (const v of byItem[itemId]) rowsHtml += `${esc(v.size)}${esc(bcBound(s, v.item, v.si))}${blind ? "" : sysOf(v.key)}`; } openPrintWindow("Count sheet", `

ThreadCount — ${plMode ? "Pre-loved pool" : "Stocktake"} count sheet

${esc(s.settings.facility)} · Scope: ${esc(group)}${loc ? " · " + esc(trail(loc)) : ""}${tq ? " · filter “" + esc(q) + "”" : ""} · ${match.length} lines · Printed ${esc(fmtDate(s.today))} · Counted by ____________ ${blind ? "· BLIND COUNT" : ""}
${rowsHtml}
SizeBarcode${blind ? "" : "System"}Counted
`, { 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" : ""}${h.locationId ? " · " + csvEsc(trail(h.locationId)) : ""}\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) : ""]; }))); } const locOpts = [{ value: "", label: "All" }, ...locTree(s).map(({ loc: l }) => ({ value: l.id, label: trail(l.id) }))]; const groupOpts = [ALL_GROUPS, ...s.settings.staffGroups.filter((g) => g !== ALL_GROUPS)].map((g) => ({ value: g, label: g })); const fileLabel = variances === 0 ? "File count" : "Apply adjustments"; return (
setScan(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && scan.trim()) handleScan(scan); }} autoFocus /> setQ(e.target.value)} /> { setLoc(v); setQuery({ location: v || null }); }} />
{pct}% counted · {scopeCounted} of {scopeAll.length}
{counted > 0 && (
{needsReason.length > 0 &&
)} {loaded && counted > 0 && savedAt &&
Saved tally · last entry {formatInZone(savedAt, s.tz, { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" })}
} {Math.min(limit, rows.length)} of {scopeAll.length}{blind ? " · blind" : ""}} foot={rows.length > limit ? : undefined}> {variants.length === 0 ?
No garments to count yet.
: rows.length === 0 ?
{loc && poolVariants.length === 0 ? "Nothing is placed under this location." : "No lines match."}
: (
{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 ( {/* Only big gaps get the chooser, blind or not: the line can't be filed without one. */} ); })}
GarmentSize{blind ? System : "System"}Counted{blind ? "Counted?" : "Variance"}Reason
{label(v.item)} {v.size} {blind ? "" : sys} setCounts({ ...counts, [ck]: e.target.value.replace(/[^0-9]/g, "") })} /> {blind ? (h ? "✓" : "") : h ? signedInt(varr) : "—"}{big && (<> {!reason[ck] &&
)}
0 ? {s.stocktakes.length} filed : undefined}> {s.stocktakes.length === 0 &&
No stocktakes filed yet.
} {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; const where = h.locationId ? trail(h.locationId) : ""; return (
{fmtDate(h.date).replace(/\s\d{4}$/, "")} {h.by}{h.mode === "preloved" ? " · pre-loved pool" : ""}{where ? ` · ${where}` : ""} · {h.counted} lines · {h.variances} variances · net {signedInt(net)} ({signedMoney(nv)})
{open && (
{h.variances === 0 && No variances.} {h.lines.filter((l) => l.counted !== l.sys).map((l, i) => { const diff = l.counted - l.sys; return (
{label(byId[l.itemId])} · {byId[l.itemId]?.sizes[l.si] ?? "?"}{l.reason ? · {l.reason} : null} {l.sys} → {l.counted} · {signedInt(diff)} ({money(Math.abs(diff) * (byId[l.itemId]?.cost || 0))})
); })}
)}
); })}
{cam && setCam(false)} />} {bind && setBind("")} onBound={(itemId, si) => setMsg(countPlus(itemId, si))} />}
); }