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
+235
View File
@@ -0,0 +1,235 @@
"use client";
/* Person Hand back (HANDBACK workstream). One basket for everything a person brings to the window:
* each line is one garment with its condition, and any line can swap for another size. The whole
* list is recorded in one handback.commit, so a return and its swap never land half way. */
import { useCallback, useEffect, useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { bcParse, key, label, onhand, plOf, staffName } from "@/lib/compute";
import { DIVIDER, INK, MBar, MButton, MChipRow, MEmpty, MONO, MRow, MSection, MUTED, useScanFlash, useToast } from "@/components/m";
import { useHeld, type Held } from "@/components/MPerson";
import { useBasket, type BackLine } from "@/components/MBasket";
import MScan from "@/components/MScan";
import type { DoneProps } from "@/components/SignFlow";
export type PersonTabProps = {
staffId: string;
/** The tab calls it in an effect; the shell renders it docked below MBody (History sets null). */
setBar: (bar: React.ReactNode) => void;
/** The shell swaps the whole screen for <DoneScreen {...d} />. */
onDone: (d: DoneProps) => void;
/** The shell shows MError under the rule. */
onError: (msg: string) => void;
};
type Cond = BackLine["cond"];
const CONDS: { value: Cond; label: string }[] = [
{ value: "Good", label: "Good" }, { value: "Damaged", label: "Damaged" }, { value: "Condemn", label: "Condemn" }, { value: "Lost", label: "Lost" },
];
/** The phone's four words for the record's four return conditions. */
const RECORDED: Record<Cond, "Returned - Good" | "Returned - Damaged" | "Written Off" | "Lost"> = {
Good: "Returned - Good", Damaged: "Returned - Damaged", Condemn: "Written Off", Lost: "Lost",
};
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
let seq = 0;
const newUid = () => `${Date.now().toString(36)}${(seq++).toString(36)}${Math.random().toString(36).slice(2, 6)}`;
// The mockup's .chip and .x; m.tsx keeps its chip style private.
const chipBtn: React.CSSProperties = {
minHeight: 44, minWidth: 48, padding: "0 12px", border: "2px solid " + INK, background: "transparent", color: INK,
fontFamily: "inherit", fontSize: 14, fontWeight: 700, borderRadius: 0, display: "inline-flex", alignItems: "center",
justifyContent: "center", cursor: "pointer", flex: "none",
};
const xBtn: React.CSSProperties = {
border: 0, background: "none", width: 44, height: 44, fontSize: 22, color: MUTED, padding: 0, cursor: "pointer", flex: "none", fontFamily: "inherit",
};
export default function HandBackTab({ staffId, setBar, onDone, onError }: PersonTabProps) {
const { s, mutate } = useSnap();
const { L, byId } = useDerived();
const toast = useToast();
const flash = useScanFlash();
const basket = useBasket();
const { setBack, clear } = basket;
const st = s.staff.find((x) => x.id === staffId);
const held = useHeld(s, staffId);
const lines = basket.back(staffId);
const [scanning, setScanning] = useState(false);
const [swapOpen, setSwapOpen] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const heldBy = useMemo(() => {
const m: Record<string, Held> = {};
for (const h of held) m[h.key] = h;
return m;
}, [held]);
const usedOf = (k: string) => lines.filter((l) => key(l.itemId, l.si) === k).length;
// The record moved under the basket (a garment taken back elsewhere): drop lines past what they hold.
useEffect(() => {
if (!lines.length) return;
const seen: Record<string, number> = {};
const kept = lines.filter((l) => {
const k = key(l.itemId, l.si);
seen[k] = (seen[k] || 0) + 1;
return seen[k] <= (heldBy[k]?.qty || 0);
});
if (kept.length !== lines.length) setBack(staffId, kept);
}, [heldBy, lines, setBack, staffId]);
/* The server takes new garments back before pre-loved ones, so the first N lines of a variant are
* new (a swap comes off the shelf, a Good one goes back on it) and the rest pre-loved (the pool). */
const preloved = useMemo(() => {
const out: Record<string, boolean> = {};
const seen: Record<string, number> = {};
for (const l of lines) {
const k = key(l.itemId, l.si);
const newUnits = (heldBy[k]?.issues || []).filter((i) => !i.preloved).reduce((t, i) => t + i.qty, 0);
out[l.uid] = (seen[k] || 0) >= newUnits;
seen[k] = (seen[k] || 0) + 1;
}
return out;
}, [lines, heldBy]);
/** What a swap to size `si` can still draw on for this line, after the other lines' swaps and
* whatever the other lines put back in good condition. */
const swapStock = (line: BackLine, si: number) => {
const k = key(line.itemId, si);
const pl = preloved[line.uid];
let n = pl ? plOf(s, k) : onhand(s, L, k);
for (const o of lines) {
if (o.uid === line.uid || preloved[o.uid] !== pl || o.itemId !== line.itemId) continue;
if (o.swapSi === si) n--;
if (o.cond === "Good" && o.si === si) n++;
}
return Math.max(0, n);
};
const add = (h: Held) => {
setBack(staffId, [...lines, { uid: newUid(), itemId: h.itemId, si: h.si, cond: "Good", swapSi: null }]);
};
const tapHeld = (h: Held) => {
if (usedOf(h.key) >= h.qty) { toast(`They only hold ${h.qty}`); return; }
add(h);
};
const onScan = (raw: string) => {
setScanning(false);
const code = raw.trim();
const v = bcParse(s, code);
const h = v ? heldBy[key(v.itemId, v.si)] : undefined;
if (!h) { toast(`${code} isnt something ${st?.first || "they"} holds`); return; }
if (held.every((x) => usedOf(x.key) >= x.qty)) { toast("Everything they hold is already on the list"); return; }
if (usedOf(h.key) >= h.qty) { toast(`They only hold ${h.qty}`); return; }
flash("Garment", `${label(byId[h.itemId])} ${h.size}`, () => add(h));
};
const update = (uid: string, patch: Partial<BackLine>) =>
setBack(staffId, lines.map((l) => (l.uid === uid ? { ...l, ...patch } : l)));
const remove = (uid: string) => {
setBack(staffId, lines.filter((l) => l.uid !== uid));
setSwapOpen(null);
};
const commit = useCallback(async () => {
if (!lines.length || saving) return;
setSaving(true);
onError("");
const sent = lines;
const r = await mutate<{ back: number; swaps: number }>("handback.commit", {
staffId,
lines: sent.map((l) => ({ itemId: l.itemId, si: l.si, cond: RECORDED[l.cond], swapSi: l.swapSi })),
});
setSaving(false);
if (!r.ok) { onError(r.error); return; }
const shelfKeys: string[] = [];
for (const l of sent) {
if (l.cond === "Good" && !preloved[l.uid]) shelfKeys.push(key(l.itemId, l.si));
if (l.swapSi !== null) shelfKeys.push(key(l.itemId, l.swapSi));
}
const n = r.result?.back ?? sent.length;
const swaps = r.result?.swaps ?? sent.filter((l) => l.swapSi !== null).length;
clear("back", staffId);
onDone({
head: `${plural(n, "item")} handed back`,
sub: staffName(st) + (swaps ? ` · ${plural(swaps, "size swap")} issued` : ""),
shelfKeys: [...new Set(shelfKeys)],
next: "scan",
});
}, [lines, saving, onError, mutate, staffId, preloved, clear, onDone, st]);
useEffect(() => {
setBar(saving
? <MBar label="Recording…" disabled small={plural(lines.length, "item")} />
: lines.length
? <MBar label="Record hand back" small={plural(lines.length, "item")} onClick={commit} />
: <MBar label="Record hand back" small="nothing yet" disabled offReason="Scan or tap what they hand back" />);
}, [setBar, saving, lines.length, commit]);
return (
<>
<MButton tone="ink" icon="scan" label="Scan what they hand back" onClick={() => setScanning(true)} />
{lines.length > 0 && (
<>
<MSection label="Handing back" right={plural(lines.length, "item")} />
{lines.map((l) => {
const it = byId[l.itemId];
const name = label(it);
const size = String(it?.sizes[l.si] ?? l.si);
const swapSize = l.swapSi === null ? null : String(it?.sizes[l.swapSi] ?? l.swapSi);
const open = swapOpen === l.uid;
const others = (it?.sizes || []).map((sz, i) => ({ sz: String(sz), i })).filter((x) => x.i !== l.si);
const counts: Record<string, number> = {};
for (const x of others) counts[String(x.i)] = swapStock(l, x.i);
const cur = l.swapSi === null ? null : String(l.swapSi);
return (
// The mockup's .cl: MLine's layout, with the swap note in ink rather than the flag's accent.
<div key={l.uid} style={{ borderBottom: "1px solid " + DIVIDER, padding: "10px 0" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<b style={{ fontWeight: 700 }}>{name}</b> <span style={{ fontFamily: MONO }}>{size}</span>
{swapSize && <div style={{ fontSize: 13, fontWeight: 800, color: INK, marginTop: 2 }}>Swap for {swapSize}</div>}
</div>
<button type="button" style={{ ...chipBtn, opacity: l.cond === "Good" ? 1 : 0.4, cursor: l.cond === "Good" ? "pointer" : "not-allowed" }}
disabled={l.cond !== "Good"} aria-expanded={open} aria-controls={`swap-${l.uid}`}
aria-label={swapSize ? `Size ${swapSize}: change the swap for ${name} ${size}` : `Swap size for ${name} ${size}`}
onClick={() => setSwapOpen(open ? null : l.uid)}>
{swapSize ? `Size ${swapSize}` : "Swap size"}
</button>
<button type="button" style={xBtn} aria-label={`Remove ${name} ${size}`} onClick={() => remove(l.uid)}>×</button>
</div>
<MChipRow grid={4} label={`Condition of ${name} ${size}`} options={CONDS} value={l.cond}
onPick={(c) => {
// Only a garment going back on the shelf swaps; anything else is replaced by an issue.
if (c === "Good") { update(l.uid, { cond: c }); return; }
update(l.uid, { cond: c, swapSi: null });
if (open) setSwapOpen(null);
}} />
{open && l.cond === "Good" && (
<div id={`swap-${l.uid}`}>
<MChipRow label={`Swap ${name} ${size} for`} value={cur}
options={others.map((x) => ({ value: String(x.i), label: x.sz, n: counts[String(x.i)] }))}
disabled={(v) => counts[v] <= 0 && cur !== v}
onPick={(v) => { update(l.uid, { swapSi: cur === v ? null : Number(v) }); setSwapOpen(null); }} />
</div>
)}
</div>
);
})}
</>
)}
<MSection label="Holding now" />
{held.length === 0
? <MEmpty title="Nothing out" />
: held.map((h) => (
<MRow key={h.key} mark="ink" title={`${label(byId[h.itemId])} ${h.size}`} sub="Tap to hand one back" right={`×${h.qty}`}
onClick={() => tapHeld(h)} />
))}
{scanning && <MScan title="Scan what they hand back" onHit={onScan} onClose={() => setScanning(false)} />}
</>
);
}