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:
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user