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
+52
View File
@@ -0,0 +1,52 @@
"use client";
/* The ink "Now counting" panel (mockup .panel): garment · size, Counted / Expected / Gap, the
* Hands-free switch, and the inline "Type a count" field when it is open. The same pieces are the
* figure and control of the live camera overlay when hands-free falls back to it. */
import { useEffect, useRef, useState } from "react";
import { GROUND, INK, MFigRow, MKick, MSwitchRow } from "@/components/m";
import { signed } from "@/components/m/count/lines";
const OFF_GREY_TEXT = "#b5b1af"; // mockup .panel .kick
export function CountFigures({ name, counted, expected }: { name: string; counted: number; expected: number }) {
const d = counted - expected;
return (
<>
<div style={{ ["--tcx-kick" as string]: OFF_GREY_TEXT } as React.CSSProperties}><MKick>Now counting</MKick></div>
<div aria-live="polite" style={{ fontSize: 19, fontWeight: 800, marginTop: 2, lineHeight: 1.2 }}>{name}</div>
<MFigRow figs={[
{ label: "Counted", n: counted },
{ label: "Expected", n: expected },
{ label: "Gap", n: signed(d), tone: d === 0 ? "ok" : "accent" },
]} />
</>
);
}
export function HandsFree({ on, onToggle, disabled }: { on: boolean; onToggle: () => void; disabled?: boolean }) {
return <MSwitchRow dark tone="accent" title="Hands-free" on={on} onToggle={onToggle} disabled={disabled} />;
}
/** Inline count field: Enter or Set applies it. Keyed by the caller on the line, so it never shows
* the previous line's figure under the new line's name. */
export function TypeCount({ name, value, onSet }: { name: string; value: number; onSet: (n: number) => void }) {
const [v, setV] = useState(String(value));
const ref = useRef<HTMLInputElement | null>(null);
useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
const set = () => { const n = parseInt(v, 10); if (Number.isFinite(n) && n >= 0) onSet(n); };
return (
<div style={{ display: "flex", gap: 8, marginTop: 10 }}>
<input ref={ref} type="text" inputMode="numeric" pattern="[0-9]*" aria-label={`Counted for ${name}`} value={v}
onChange={(e) => setV(e.target.value.replace(/[^0-9]/g, "").slice(0, 5))}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); set(); } }}
style={{ flex: 1, minWidth: 0, height: 52, border: "2px solid #57534f", background: "#fff", color: INK, fontFamily: "inherit", fontSize: 16, padding: "0 14px", borderRadius: 0, boxSizing: "border-box" }} />
<button type="button" onClick={set}
style={{ minHeight: 52, padding: "0 18px", border: "2px solid " + GROUND, background: "transparent", color: GROUND, fontFamily: "inherit", fontSize: 14, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer" }}>Set</button>
</div>
);
}
/** The panel as it sits at the top of the counting body (bleeds over MBody pad). */
export function CountPanel({ children }: { children: React.ReactNode }) {
return <section aria-label="Now counting" style={{ background: INK, color: GROUND, margin: "-16px -16px 12px", padding: "14px 16px 16px" }}>{children}</section>;
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
/* The lines a shelf count lists, shared by the counting screen and Check the gaps.
*
* The two screens have to list exactly the same set: anything countable on one and missing on the
* other is counted on the phone and then dropped at commit, with the tally cleared behind it.
*
* A placed size is countable even with no stock history (a shelf being set up). The unplaced bucket
* needs a test or it would be the whole catalogue: stock history, or a bound barcode (somebody
* scanned that label onto that size, so the garment physically exists). /m/count uses the same
* test through `looseVariants` for its "Not on a shelf yet" row. */
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { UNPLACED, bcBound, label, locMap, locSubtree, locTrail, locUnder, onhand, touched } from "@/lib/compute";
import type { Item, Ledger, Snapshot, Variant } from "@/lib/compute";
export type CountLine = {
key: string; itemId: string; si: number; size: string; item: Item;
expected: number; code: string; where: string;
};
/** Unplaced sizes worth counting: stock history or a bound barcode. */
export function looseVariants(s: Snapshot, L: Ledger, variants: Variant[]): Variant[] {
return variants.filter((v) => !s.placed[v.key] && (touched(s, L, v.key) || !!bcBound(s, v.item, v.si)));
}
export function useCountLines(locationId: string) {
const { s } = useSnap();
const { L, variants } = useDerived();
const locs = useMemo(() => locMap(s), [s]);
const lines = useMemo<CountLine[]>(() => {
const picked = locationId === UNPLACED
? looseVariants(s, L, variants)
: (() => { const sub = locSubtree(s, locationId); return variants.filter((v) => sub.has(s.placed[v.key] || "")); })();
return picked.map((v) => ({
key: v.key, itemId: v.itemId, si: v.si, size: v.size, item: v.item,
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 locName = locationId === UNPLACED ? "Not on a shelf" : locTrail(locs, locationId, 0) || locs[locationId]?.name || "Location";
return { lines, locName, locs };
}
/** "Scrub top (W)": the garment as a row title; the size follows it. */
export const lineTitle = (l: Pick<CountLine, "item">) => label(l.item);
/** Gap with its sign, using a true minus: "+3", "12", "0". */
export const signed = (d: number) => (d === 0 ? "0" : d > 0 ? `+${d}` : `${-d}`);
/** The reasons a count gap can carry, stored as the stocktake line's reason. A short count gets the
* first list, a count over what was expected the second. */
export const SHORT_REASONS = ["At laundry", "Condemned", "Missing", "Other"] as const;
export const OVER_REASONS = ["Found extra", "Other"] as const;