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 2d04e45 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user