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 38e16eb on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-16 07:57:54 +10:00
commit 0910bc32c1
457 changed files with 55952 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
"use client";
/* Count a shelf: every location holding garments, plus the unplaced bucket, each shelf with a
shelf-label print chip. Mounted by app/m/(app)/count/page.tsx (a Stock detail, no tab bar). */
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { UNPLACED, daysBetween } from "@/lib/compute";
import { printShelfLabel } from "@/lib/nativeprint";
import { isNative } from "@/lib/nativescan";
import { INK, MBody, MEmpty, MRow, MRule, MSection, MTop, MTopCount, useToast } from "@/components/m";
import { countRows } from "@/components/m/stock/stockdata";
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 `Counted ${n} days ago`;
}
export default function CountList() {
const { s } = useSnap();
const { L, variants } = useDerived();
const toast = useToast();
const rows = useMemo(() => countRows(s, L, variants), [s, L, variants]);
const print = async (id: string, name: string) => {
const r = await printShelfLabel({ locationId: id, copies: 1 });
if (!r.ok) toast(r.error);
else if (isNative()) toast(`${name} label sent to the shelf printer`);
};
const chip: React.CSSProperties = {
minHeight: 44, minWidth: 64, padding: "0 12px", border: "2px solid " + INK, background: "transparent", color: INK,
fontFamily: "inherit", fontSize: 13, fontWeight: 800, letterSpacing: "0.05em", textTransform: "uppercase", cursor: "pointer", flex: "none",
};
return (
<>
<MTop title="Count a shelf" back right={<MTopCount>{rows.length}</MTopCount>} />
<MRule />
<MBody pad>
{rows.length === 0 ? (
<MEmpty title="Nothing to count yet" sub="Place garments on a shelf in the portal." />
) : (
<>
<MSection label="Shelves" right="lines · units" />
{rows.map((r) => (
<div key={r.id} style={{ display: "flex", alignItems: "center", gap: 10, borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ flex: 1, minWidth: 0, marginBottom: -1 }}>
<MRow 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={`${r.lines} · ${r.units}`} />
</div>
{r.id !== UNPLACED && (
<button type="button" style={chip} onClick={() => print(r.id, r.name)} aria-label={`Print a shelf label for ${r.name}`}>Label</button>
)}
</div>
))}
</>
)}
</MBody>
</>
);
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
/* The print sheet over a stock line: copies, why, and the bar that sends it. A native shell hands the
label page to Android printing; a browser opens the printable label page. */
import { useEffect, useState } from "react";
import { printLabels, printState, type PrintState } from "@/lib/nativeprint";
import { MBar, MChipRow, MKick, MRow, MSheet, MStepper, useToast } from "@/components/m";
const REASONS = ["Torn", "Faded", "New shelf"] as const;
type Reason = (typeof REASONS)[number];
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
export default function PrintSheet({ open, onClose, code, title }: { open: boolean; onClose: () => void; code: string; title: string }) {
const toast = useToast();
const [copies, setCopies] = useState(1);
const [reason, setReason] = useState<Reason>("Torn");
const [state, setState] = useState<PrintState | null>(null);
const [sending, setSending] = useState(false);
useEffect(() => {
if (!open) return;
setCopies(1); setReason("Torn");
let live = true;
printState().then((p) => { if (live) setState(p); });
return () => { live = false; };
}, [open]);
const go = async () => {
if (sending) return;
setSending(true);
const r = await printLabels({ code, copies, reason });
setSending(false);
if (!r.ok) { toast(r.error); return; }
onClose();
toast(state?.state === "browser" ? `${plural(copies, "label")} opened to print` : `${plural(copies, "label")} sent to the shelf printer`);
};
return (
<MSheet open={open} onClose={onClose} labelId="tc-print-title"
bar={<MBar label={sending ? "Sending…" : `Print ${plural(copies, "label")}`} onClick={go} disabled={sending} />}>
<MKick>Shelf printer{state ? ` · ${state.label}` : ""}</MKick>
<h2 id="tc-print-title" style={{ fontSize: 20, fontWeight: 900, margin: "2px 0 0", lineHeight: 1.15 }}>{title}</h2>
<MRow title="Copies" sub="Barcode, garment, size, shelf"
right={<MStepper n={copies} onChange={setCopies} min={1} max={20} label="copies" />} />
<MChipRow label="Why it is being reprinted" value={reason} onPick={setReason}
options={REASONS.map((r) => ({ value: r, label: r }))} />
</MSheet>
);
}
+72
View File
@@ -0,0 +1,72 @@
/* Figures the Stock tab and a stock line's page both read, so the two never disagree. Pure. */
import { UNPLACED, bcBound, daysBetween, isOpen, key, locSubtree, locTree, onhand, touched, type Item, type Ledger, type Snapshot, type Variant } from "@/lib/compute";
export type CountRow = { id: string; name: string; depth: number; lines: number; units: number; last: string | undefined };
/** The shelves Count a shelf lists: every location holding placed garments, then the unplaced bucket
* (the same test the counting and variance screens use for it). */
export function countRows(s: Snapshot, L: Ledger, variants: Variant[]): CountRow[] {
const lastAt: Record<string, string> = {};
for (const t of s.stocktakes) if (t.mode !== "preloved" && t.locationId && (!lastAt[t.locationId] || t.date > lastAt[t.locationId])) lastAt[t.locationId] = t.date;
const out: CountRow[] = locTree(s).map(({ loc, depth }) => {
const sub = locSubtree(s, loc.id);
const mine = variants.filter((v) => sub.has(s.placed[v.key] || ""));
return { id: loc.id, name: loc.name, depth, lines: mine.length, units: mine.reduce((t, v) => t + onhand(s, L, v.key), 0), last: lastAt[loc.id] };
}).filter((r) => r.lines > 0);
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", depth: 0, lines: loose.length, units: loose.reduce((t, v) => t + onhand(s, L, v.key), 0), last: undefined });
return out;
}
/** Outstanding units per variant key on open orders placed with a supplier (drafts excluded) —
* the same set onOrderText() reads for one size. */
export function placedOnOrder(s: Snapshot, byId: Record<string, Item>): Record<string, number> {
const out: Record<string, number> = {};
for (const o of s.orders) {
if (!isOpen(o) || o.status === "Draft") continue;
const got: Record<string, number> = {};
for (const rc of o.receipts) for (const l of rc.lines) got[l.itemId + "|" + l.size] = (got[l.itemId + "|" + l.size] || 0) + l.qty;
for (const l of o.lines) {
const g = l.itemId + "|" + l.size;
const done = Math.min(l.qty, got[g] || 0);
got[g] = (got[g] || 0) - done;
if (l.qty <= done) continue;
const it = byId[l.itemId];
if (!it) continue;
const si = it.sizes.findIndex((z) => String(z) === l.size);
if (si < 0) continue;
const k = key(it.id, si);
out[k] = (out[k] || 0) + l.qty - done;
}
}
return out;
}
/** "Today", "Yesterday", "n days ago" or "Never", from the newest shelf count holding this line. */
export function lastCountedText(s: Snapshot, itemId: string, si: number): string {
let last = "";
for (const t of s.stocktakes) {
if (t.mode === "preloved" || (last && t.date <= last)) continue;
if (t.lines.some((l) => l.itemId === itemId && l.si === si)) last = t.date;
}
if (!last) return "Never";
const n = daysBetween(last, s.today);
return n <= 0 ? "Today" : n === 1 ? "Yesterday" : `${n} days ago`;
}
/** Units of one size out in staff hands now: issued, not returned, not handed in. */
export function heldByStaff(s: Snapshot, itemId: string, si: number): number {
let n = 0;
for (const i of s.issues) if (i.itemId === itemId && i.si === si && !i.returned && !i.handedIn) n += i.qty;
return n;
}
type Ranked = { oh: number; par: number; name: string };
/** Worst first: on hand as a share of par, par 0 last. */
export function byShortfall(a: Ranked, b: Ranked): number {
const ra = a.par > 0 ? a.oh / a.par : Infinity, rb = b.par > 0 ? b.oh / b.par : Infinity;
if (ra !== rb) return ra < rb ? -1 : 1;
return a.oh - b.oh || a.name.localeCompare(b.name);
}
/** Rows drawn per page on the long lists; the rest arrive with "Show more", never silently cut. */
export const PAGE = 200;