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).
This commit is contained in:
ThreadCount
2026-09-13 11:38:24 +10:00
commit 96d5c10537
274 changed files with 43338 additions and 0 deletions
+220
View File
@@ -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} 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}
/>
)}
</>
);
}
+171
View File
@@ -0,0 +1,171 @@
"use client";
/* Variance — only the lines that don't match, what happens when the count commits, and the commit.
A gap at or over the facility's threshold has to carry a reason before anything is filed. */
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { UNPLACED, bcBound, formatInZone, locMap, locSubtree, locUnder, onhand, reorderAt, touched, variantName } from "@/lib/compute";
import { INK, MBar, MBody, MEmpty, MError, MRule, MTop, MPanel, MInkLink } from "@/components/m";
import { clearCount, readCount } from "@/lib/opencount";
const REASONS = ["At laundry", "Condemned", "Missing", "Other"];
export default function MVariance() {
const { s, mutate, busy } = useSnap();
const { L, byId, variants } = useDerived();
const router = useRouter();
const locationId = String(useParams().id || "");
const locs = useMemo(() => locMap(s), [s]);
const locName = locationId === UNPLACED ? "Not on a shelf" : locs[locationId]?.name || "Location";
const lines = useMemo(() => {
const sub = locationId === UNPLACED ? null : locSubtree(s, locationId);
// Exactly the set the counting screen lists, and it has to stay the same test. A placed size
// counts even with no history, and so does an unplaced size with a barcode bound to it —
// somebody stood at the counter and scanned that label onto that size, which is why the
// counting screen lets you count it. Leave that arm off here and a size counted on the phone
// has no row on this screen and no line in the payload: committing files a stocktake without
// it, the garments found on the trolley are never counted in, and clearCount() then wipes the
// tally that was the only record they had been found.
return variants
.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> | null>(null);
const [savedAt, setSavedAt] = useState("");
const [reason, setReason] = useState<Record<string, string>>({});
const [accepted, setAccepted] = useState<Record<string, boolean>>({});
const [err, setErr] = useState("");
// The tally belongs to the person who took it, so it is read back under their own key — the
// counting screen writes it under theirs. When it was taken matters as much as what it says:
// a count resumed the next morning has had a night of issuing against it, and the screen should
// say when it was last touched rather than present a stale tally as if it were fresh.
const me = s.session.userId;
useEffect(() => {
const open = readCount(me, locationId);
setCounted(open?.n ?? {});
setSavedAt(open?.savedAt ?? "");
}, [me, locationId]);
const gate = Math.max(1, s.settings.varianceReason);
const off = useMemo(() => (counted ? lines.filter((l) => (counted[l.key] ?? 0) !== l.expected) : []), [counted, lines]);
const totalCounted = counted ? lines.reduce((t, l) => t + (counted[l.key] ?? 0), 0) : 0;
const totalExpected = lines.reduce((t, l) => t + l.expected, 0);
const needsReason = off.filter((l) => Math.abs((counted?.[l.key] ?? 0) - l.expected) >= gate && !reason[l.key]);
// What the shelf will look like once this commits — not what the commit does. Committing a count
// writes stock adjustments and the stocktake itself and nothing else; the reorder draft is a
// separate, deliberate step on Reorder, which is where the quantities can still be changed
// before anything goes to a supplier.
const willReorder = useMemo(() => {
if (!counted) return { lines: 0, units: 0 };
let n = 0, units = 0;
for (const l of lines) {
const after = counted[l.key] ?? 0;
const par = reorderAt(s, l.key);
if (after <= par && l.expected > par) { n++; units += Math.max(0, par * 2 - after); }
}
return { lines: n, units };
}, [counted, lines, s]);
const commit = useCallback(async () => {
if (!counted) return;
if (needsReason.length) { setErr(`A gap of ${gate} or more needs a reason — ${needsReason.length} line${needsReason.length === 1 ? "" : "s"} still to go.`); return; }
const payload = lines.map((l) => ({ itemId: l.itemId, si: l.si, counted: counted[l.key] ?? 0, reason: reason[l.key] || "" }));
const r = await mutate("stocktake.apply", { lines: payload, mode: "shelf", locationId: locationId === UNPLACED ? "" : locationId });
if (!r.ok) { setErr(r.error); return; }
clearCount(me, locationId);
// A count that leaves lines below par hands straight over to Reorder. Nothing is drafted by
// the commit itself, and a count that ends on the home screen is a count whose shortfall
// nobody ever goes back for.
router.push(willReorder.lines > 0 ? "/m/reorder" : "/m?counted=1");
}, [counted, lines, reason, needsReason.length, gate, mutate, me, locationId, router, willReorder.lines]);
if (!counted) return (<><MTop title="Variance" back /><MRule /><MBody /></>);
return (
<>
<MTop title="Variance" back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody>
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.03em", lineHeight: 1.05 }}>
{off.length === 0 ? "Everything matches" : `${off.length} line${off.length === 1 ? "" : "s"} dont match`}
</h2>
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 8 }}>{locName} · counted {totalCounted} of {totalExpected} expected</p>
{savedAt && (
<p style={{ fontSize: 13, color: "var(--color-neutral-700)", marginTop: 4 }}>
Tallied {formatInZone(savedAt, s.tz, { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" })}.
{" "}Anything issued since then is already off the expected figure.
</p>
)}
</div>
{off.length === 0 ? (
<MEmpty title="No gaps to explain" sub="Every line came out at what the system expected. Commit the count to file it against this shelf." />
) : off.map((l) => {
const n = counted[l.key] ?? 0;
const d = n - l.expected;
const big = Math.abs(d) >= gate;
return (
<div key={l.key} style={{ padding: 16, background: "#fff", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, letterSpacing: "-0.02em" }}>{variantName(byId[l.itemId], l.size)}</div>
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 4 }}>{[l.code || (l.item.sku ? `SKU ${l.item.sku}` : "No barcode bound"), l.where].filter(Boolean).join(" · ")}</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em", color: "var(--color-accent-700)", fontVariantNumeric: "tabular-nums" }}>{d > 0 ? `+${d}` : `${-d}`}</div>
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 2 }}>{n} of {l.expected}</div>
</div>
</div>
<div style={{ display: "flex", gap: 8, marginTop: 14 }}>
<button onClick={() => router.push(`/m/count/${locationId}`)}
style={{ flex: 1, minHeight: 44, border: "2px solid " + INK, background: "transparent", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>Recount</button>
<button onClick={() => setAccepted((a) => ({ ...a, [l.key]: !a[l.key] }))} aria-pressed={!!accepted[l.key]}
style={{ flex: 1, minHeight: 44, border: "2px solid " + INK, background: accepted[l.key] ? INK : "transparent", color: accepted[l.key] ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>
{accepted[l.key] ? "Accepted" : "Accept"}
</button>
</div>
{big && (
<div style={{ marginTop: 14, padding: 14, background: "var(--color-bg)" }}>
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-accent-700)" }}>
A gap of {gate} or more needs a reason
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 10 }}>
{REASONS.map((r) => {
const on = reason[l.key] === r;
return (
<button key={r} onClick={() => setReason((x) => ({ ...x, [l.key]: on ? "" : r }))} aria-pressed={on}
style={{ minHeight: 44, padding: "0 14px", border: "2px solid " + INK, background: on ? INK : "transparent", color: on ? "var(--color-bg)" : INK, fontSize: 13, fontWeight: 700, cursor: "pointer" }}>{r}</button>
);
})}
</div>
</div>
)}
</div>
);
})}
<div style={{ padding: 16 }}>
<MPanel kicker="After this count">
<p style={{ fontSize: 14, lineHeight: 1.6, margin: 0 }}>
{willReorder.lines === 0
? "Nothing falls below par when this commits, so there is nothing to reorder."
: `${willReorder.lines} line${willReorder.lines === 1 ? "" : "s"} will be below par once this count commits — about ${willReorder.units} item${willReorder.units === 1 ? "" : "s"} to order. Committing orders nothing on its own: it takes you to Reorder, where you raise the draft.`}
</p>
<p style={{ fontSize: 13, lineHeight: 1.6, marginTop: 10, color: "var(--color-neutral-400)" }}>Nothing is sent to a supplier without approval.</p>
{willReorder.lines > 0 && <div style={{ marginTop: 14 }}><MInkLink label="Reorder" href="/m/reorder" /></div>}
</MPanel>
</div>
</MBody>
<MBar label={busy ? "Committing…" : "Commit count"} glyph="check" onClick={commit} disabled={busy || needsReason.length > 0}
sub={needsReason.length ? `${needsReason.length} gap${needsReason.length === 1 ? " still needs" : "s still need"} a reason` : undefined} />
</>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
/* Stocktake — choose what you're counting. One row per location that actually holds garments,
plus everything not yet placed, so nothing on the shelf is uncountable. */
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { UNPLACED, bcBound, daysBetween, locSubtree, locTree, onhand, touched } from "@/lib/compute";
import { INK, MBody, MEmpty, MNav, MNote, MRow, MRule, MSection, MTop } from "@/components/m";
/* "Last counted 0 days ago" and "1 days ago" are how a shelf counted this morning used to read. */
function lastCounted(last: string | undefined, today: string): string {
if (!last) return "Never counted";
const n = daysBetween(last, today);
if (n <= 0) return "Counted today";
if (n === 1) return "Counted yesterday";
return `Last counted ${n} days ago`;
}
export default function MCountStart() {
const { s } = useSnap();
const { L, variants } = useDerived();
const rows = useMemo(() => {
const lastAt: Record<string, string> = {};
for (const t of s.stocktakes) if (t.mode !== "preloved" && t.locationId && !lastAt[t.locationId]) lastAt[t.locationId] = t.date;
const out = locTree(s).map(({ loc, depth }) => {
const sub = locSubtree(s, loc.id);
// Placed on the shelf is enough to make a shelf countable. A location holding only sizes
// that have never been stocked is exactly the shelf someone needs to count in.
const mine = variants.filter((v) => sub.has(s.placed[v.key] || ""));
return { id: loc.id, name: loc.name, kind: loc.kind, depth, lines: mine.length, units: mine.reduce((t, v) => t + onhand(s, L, v.key), 0), last: lastAt[loc.id] as string | undefined };
}).filter((r) => r.lines > 0);
// Only the unplaced bucket needs a test at all — without one it would list the whole
// catalogue. A bound barcode counts as much as stock history does: somebody stood at the
// counter with the garment in hand and scanned its label onto that size, which says the size
// physically exists even when no stock figure does. The counting and variance screens filter
// the unplaced bucket with exactly this expression and all three have to agree — a room whose
// unplaced sizes are all barcode-bound and never yet stocked otherwise gets no "Not on a shelf
// yet" row here, and the one screen that could count them in is unreachable from the menu.
const loose = variants.filter((v) => !s.placed[v.key] && (touched(s, L, v.key) || !!bcBound(s, v.item, v.si)));
if (loose.length) out.push({ id: UNPLACED, name: "Not on a shelf yet", kind: "", depth: 0, lines: loose.length, units: loose.reduce((t, v) => t + onhand(s, L, v.key), 0), last: undefined });
return out;
}, [s, L, variants]);
return (
<>
<MTop title="Stocktake" right={`${rows.length} location${rows.length === 1 ? "" : "s"}`} />
<MRule />
<MBody>
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.03em", lineHeight: 1.05 }}>Where are you counting?</h2>
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 10, lineHeight: 1.6 }}>
Scan every garment on the shelf. Each scan adds one to that line, and the expected figure stays on screen the whole way.
</p>
</div>
{rows.length === 0 ? (
<MEmpty
title="Nothing to count yet"
sub="A location shows up here once garments are placed on it. Set your shelves up in Settings on the desktop, then place each size against one."
/>
) : (
<>
<MSection label="Locations" right="Lines · units" />
{rows.map((r) => (
<MRow key={r.id} href={`/m/count/${r.id}`} mark={r.id === UNPLACED ? "mute" : "ink"}
title={<span style={{ paddingLeft: r.depth * 14 }}>{r.name}</span>}
sub={<span style={{ paddingLeft: r.depth * 14 }}>{lastCounted(r.last, s.today)}</span>}
right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, fontVariantNumeric: "tabular-nums" }}>{r.lines} · {r.units}</span>} />
))}
</>
)}
<MNote>A count stays open until you commit it, so you can put the phone down halfway along a shelf and pick it up again.</MNote>
</MBody>
<MNav />
</>
);
}