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 d947f89 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-15 18:27:45 +10:00
commit 7d650e4c10
298 changed files with 45868 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
"use client";
/* Size exchange — one movement, not a return followed by an issue. What comes back, what goes out,
and the staff record updated so nobody hands them the wrong size again next month. */
import { useCallback, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { isPantItem, isTopItem, key, label, onhand, staffName } from "@/lib/compute";
import MScan from "@/components/MScan";
import { GROUND, INK, MBar, MBody, MChips, MEmpty, MError, MRow, MRule, MSection, MTop } from "@/components/m";
import { useHeld, type Held } from "@/components/MPerson";
export default function MExchange() {
const { s, mutate, busy } = useSnap();
const { L, byId } = useDerived();
const router = useRouter();
const id = String(useParams().id || "");
const st = s.staff.find((x) => x.id === id);
const held = useHeld(s, id);
const [pick, setPick] = useState<Held | null>(null);
const [si, setSi] = useState(-1);
const [scan, setScan] = useState(false);
const [err, setErr] = useState("");
const it = pick ? byId[pick.itemId] : undefined;
const stock = useMemo(() => {
if (!it) return [] as number[];
return it.sizes.map((_, i) => onhand(s, L, key(it.id, i)));
}, [it, s, L]);
const onCode = useCallback((raw: string) => {
const k = s.barcodes[raw.trim()];
const hit = held.find((h) => h.key === k);
if (!hit) { setErr(`${raw.trim()} isnt something ${st?.first ?? "they"} is holding.`); return; }
setPick(hit); setSi(-1); setErr("");
}, [s.barcodes, held, st]);
if (!st) return (<><MTop title="Exchange" back /><MRule /><MBody><MEmpty title="No such staff member" /></MBody></>);
const commit = async () => {
if (!pick || si < 0) return;
const r = await mutate<{ size: string }>("issue.exchange", { id: pick.issues[0].id, si, qty: 1 });
if (!r.ok) { setErr(r.error); return; }
router.push(`/m/person/${st.id}`);
};
const willUpdate = it && (isTopItem(it) || isPantItem(it));
return (
<>
<MTop title="Exchange" back right={staffName(st)} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody>
{!pick ? (
<>
<MSection label="What doesnt fit?" right={`${held.reduce((t, h) => t + h.qty, 0)} out`} />
{held.length === 0
? <MEmpty title="Nothing to exchange" sub={`${staffName(st)} has no garments out at the moment.`} />
: held.map((h) => <MRow key={h.key} onClick={() => { setPick(h); setSi(-1); }} mark="ink" title={h.name} sub={`${h.qty} held`} />)}
{/* Same rule as the return screen: the scan bar is off when nothing is out, so the line
offering a scan goes with it. */}
{held.length > 0 && <div style={{ padding: "18px 16px", fontSize: 14, color: "var(--color-neutral-700)" }}>Or scan the garment theyve brought back.</div>}
</>
) : (
<>
<section style={{ background: INK, color: GROUND, padding: 16 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-300)" }}>Taking back</div>
<div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 8 }}>
<span aria-hidden="true" style={{ width: 4, height: 34, background: "#fff" }} />
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 20, letterSpacing: "-0.02em" }}>{pick.name}</span>
</div>
<button onClick={() => setPick(null)} style={{ marginTop: 12, background: "none", border: 0, padding: 0, color: "#fff", fontSize: 13, fontWeight: 700, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Choose a different garment</button>
</section>
<MSection label="Giving out" right={it ? label(it) : ""} />
<div style={{ padding: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span aria-hidden="true" style={{ width: 4, height: 34, background: "var(--color-accent)" }} />
<span style={{ fontSize: 14, color: "var(--color-neutral-700)" }}>Pick the size that fits. Greyed sizes are the one coming back, or have none on the shelf.</span>
</div>
{it && <MChips sizes={it.sizes.map(String)} value={si} onPick={(i) => setSi(i)} disabled={(i) => i === pick.si || stock[i] <= 0} />}
{si >= 0 && it && (
<p style={{ fontSize: 13.5, color: "var(--color-neutral-700)", marginTop: 14, lineHeight: 1.6 }}>
{stock[si]} on the shelf in size {it.sizes[si]}. The old garment goes back to stock in the same movement
{willUpdate ? `, and ${st.first}s recorded size becomes ${it.sizes[si]}.` : "."}
</p>
)}
</div>
</>
)}
</MBody>
{pick
? <MBar label={busy ? "Recording…" : si >= 0 && it ? `Exchange for size ${it.sizes[si]}` : "Pick a size"} glyph="check" onClick={commit} disabled={busy || si < 0} />
: <MBar label="Scan the garment" glyph="scan" onClick={() => setScan(true)} disabled={held.length === 0} />}
{scan && <MScan title="Scan the garment" onHit={(r) => { onCode(r); setScan(false); }} onClose={() => setScan(false)} />}
</>
);
}