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 8685140 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-13 11:09:20 +10:00
commit 822c0b7c0b
406 changed files with 47742 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)} />}
</>
);
}
+117
View File
@@ -0,0 +1,117 @@
"use client";
/* Person record — who they are, what they're holding, what has happened, and the three things
you can do about it. Issuing starts here: 1B, person first. */
import Link from "next/link";
import { useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { useSnap } from "@/lib/client";
import { fmtDate, itemMap, staffName, variantName } from "@/lib/compute";
import { GROUND, INK, MBar, MBody, MEmpty, MError, MRow, MRule, MSection, MTop } from "@/components/m";
import { MPersonHead, useHeld } from "@/components/MPerson";
export default function MPersonPage() {
const { s, isAdmin, mutate } = useSnap();
const id = String(useParams().id || "");
const st = s.staff.find((x) => x.id === id);
const held = useHeld(s, id);
const byId = useMemo(() => itemMap(s), [s]);
// Shown once, then gone: the code is a credential and is never in the snapshot.
const [code, setCode] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const origin = typeof window === "undefined" ? "threadcount.tech" : window.location.host;
const history = useMemo(() => {
if (!st) return [];
const out: { text: string; date: string }[] = [];
for (const i of s.issues) {
if (i.staffId !== id) continue;
const it = byId[i.itemId];
const size = String(it?.sizes[i.si] ?? i.si);
out.push({ text: `Issued ${i.qty} × ${variantName(it, size)}`, date: i.date });
if (i.returned) out.push({ text: `${i.returned.cond}${variantName(it, size)}`, date: i.returned.date });
if (i.handedIn) out.push({ text: `Handed in — ${variantName(it, size)}`, date: i.handedIn });
}
return out.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)).slice(0, 25);
}, [s, id, st, byId]);
if (!st) return (<><MTop title="Person" back /><MRule /><MBody><MEmpty title="No such staff member" sub="They may have been removed from the register." /></MBody></>);
const total = held.reduce((t, h) => t + h.qty, 0);
/* Issue, Exchange and Return are docked at the foot of the window with nothing underneath them,
so Android draws the gesture handle across their bottom edge. The inset goes inside the bar the
way the shared MBar and MAction take it — same custom property, so an ancestor that zeroes it
for a bar sitting mid-screen would zero this one too — and the accent still runs to the bottom
of the glass while the words stay above the handle. Without it the lower third of "Issue" is
untappable, and this is the row a counter hand hits all day. */
const SAFE_BOTTOM = "var(--tcx-safe-bottom, env(safe-area-inset-bottom, 0px))";
const foot: React.CSSProperties = { flex: 1, minHeight: `calc(64px + ${SAFE_BOTTOM})`, display: "flex", alignItems: "center", padding: `0 16px ${SAFE_BOTTOM}`, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", textDecoration: "none" };
return (
<>
<MTop title="Person" back />
<MRule />
<MBody>
<MPersonHead s={s} st={st} />
<MSection label="Holding now" right={`${total} item${total === 1 ? "" : "s"}`} />
{held.length === 0
? <div style={{ padding: "22px 16px", fontSize: 14, color: "var(--color-neutral-600)" }}>Nothing out at the moment.</div>
: held.map((h) => <MRow key={h.key} title={h.name} right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, fontVariantNumeric: "tabular-nums" }}>{h.qty}</span>} />)}
<MSection label="Their own record" />
{code ? (
<>
<div style={{ padding: "16px" }}>
<div style={{ fontFamily: "ui-monospace, Menlo, Consolas, monospace", fontSize: 26, fontWeight: 800, letterSpacing: "0.06em" }}>{code}</div>
<p style={{ fontSize: 13.5, lineHeight: 1.55, color: "var(--color-neutral-700)", margin: "8px 0 0" }}>
Read this out or write it down now it can&apos;t be shown again. They go to{" "}
<b>{origin}/my</b>, choose &ldquo;I have a code&rdquo;, and set an email and password.
</p>
</div>
<MBar label="Done" tone="ink" glyph="none" onClick={() => setCode(null)} />
</>
) : st.selfEmail ? (
<div style={{ padding: "16px", fontSize: 14, lineHeight: 1.55, color: "var(--color-neutral-600)" }}>
Signed up as {st.selfEmail} they can look up their own record instead of coming to the counter.
</div>
) : (
<>
<div style={{ padding: "16px" }}>
<p style={{ fontSize: 14, lineHeight: 1.55, color: "var(--color-neutral-600)", margin: 0 }}>
{st.selfCode
? "A code is out but hasnt been used. Make a new one if theyve lost it — the old one stops working."
: "Give them a code and they can check what they hold on their own phone. Read-only."}
</p>
</div>
<MError msg={err} onDismiss={() => setErr("")} />
{isAdmin && (
<MBar label={busy ? "Generating…" : st.selfCode ? "New code" : "Generate a code"} tone="ink" glyph="none" disabled={busy}
onClick={async () => {
setBusy(true); setErr("");
const r = await mutate<{ code: string }>("staff.selfCode", { id: st.id });
setBusy(false);
if (!r.ok) { setErr(r.error); return; }
setCode(r.result.code);
}} />
)}
</>
)}
<MSection label="History" />
{history.length === 0
? <div style={{ padding: "22px 16px", fontSize: 14, color: "var(--color-neutral-600)" }}>Nothing recorded for {staffName(st)} yet.</div>
: history.map((h, i) => (
<div key={i} style={{ display: "flex", gap: 12, padding: "14px 16px", borderBottom: "1px solid var(--color-divider)" }}>
<span style={{ flex: 1, fontSize: 14.5 }}>{h.text}</span>
<span style={{ fontSize: 13, color: "var(--color-neutral-600)", whiteSpace: "nowrap" }}>{fmtDate(h.date)}</span>
</div>
))}
</MBody>
<div style={{ display: "flex", flex: `0 0 calc(64px + ${SAFE_BOTTOM})`, borderTop: "2px solid " + INK }}>
<Link href={`/m/issue/${st.id}`} style={{ ...foot, background: "var(--color-accent)", color: "#fff" }}>Issue</Link>
<Link href={`/m/person/${st.id}/exchange`} style={{ ...foot, background: "var(--color-neutral-200)", color: INK, borderLeft: "1px solid " + GROUND }}>Exchange</Link>
<Link href={`/m/person/${st.id}/return`} style={{ ...foot, background: "var(--color-neutral-200)", color: INK, borderLeft: "1px solid " + GROUND }}>Return</Link>
</div>
</>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
/* Return — scan the garment or pick it off what they're holding, say how many and what state
they're in, confirm. The conditions are ThreadCount's real four: only "fit for use" puts a
garment back on the shelf. */
import { useCallback, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSnap } from "@/lib/client";
import { fmtDate, staffName } from "@/lib/compute";
import MScan from "@/components/MScan";
import { INK, MBar, MBody, MEmpty, MError, MRow, MRule, MSection, MStepper, MTop } from "@/components/m";
import { useHeld, type Held } from "@/components/MPerson";
const CONDITIONS: [string, string, string][] = [
["Returned - Good", "Fit for use — back to shelf", "Counts back into stock the moment its confirmed."],
["Returned - Damaged", "Damaged — needs repair", "Stays off the shelf and stays charged to the cost centre."],
["Written Off", "Condemn — beyond repair", "Written off. Nothing comes back to stock."],
["Lost", "Lost", "Never came back. Stays charged."],
];
export default function MReturn() {
const { s, mutate, busy } = useSnap();
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);
// How many of that garment are actually on the counter. Three of a size can be out on one issue
// line, and one pair coming back is one pair — crediting the whole line put two garments that
// are still on a ward back onto the shelf.
const [qty, setQty] = useState(1);
const [cond, setCond] = useState("Returned - Good");
const [scan, setScan] = useState(false);
const [err, setErr] = useState("");
const choose = useCallback((h: Held) => { setPick(h); setQty(h.qty); setErr(""); }, []);
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; }
choose(hit);
}, [s.barcodes, held, st, choose]);
if (!st) return (<><MTop title="Return" back /><MRule /><MBody><MEmpty title="No such staff member" /></MBody></>);
const confirm = async () => {
if (!pick) return;
// What they hold in this size can be spread over several issue lines, so returning four of
// them is several movements. Oldest line first — the garment that has been out longest is the
// one that came back — and the last line is split when it is only partly returned.
const rows = [...pick.issues].sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
let left = Math.min(qty, pick.qty);
const asked = left;
for (const i of rows) {
if (left <= 0) break;
const take = Math.min(i.qty, left);
const r = await mutate("issue.return", { id: i.id, cond, qty: take });
if (!r.ok) {
// Some of them may already be back. Say so rather than leave the counter to guess, and
// send them back to a fresh list rather than acting on what is now a stale row.
setErr(asked - left > 0 ? `${asked - left} of ${asked} went back before this stopped — ${r.error}` : r.error);
setPick(null);
return;
}
left -= take;
}
router.push(`/m/person/${st.id}`);
};
return (
<>
<MTop title="Return" back right={staffName(st)} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody>
{pick ? (
<>
<div style={{ padding: "20px 16px", background: "#fff", borderBottom: "2px solid " + INK }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em" }}>{pick.name}</div>
<div style={{ fontSize: 14, color: "var(--color-neutral-600)", marginTop: 6 }}>
Issued to {staffName(st)}, {fmtDate(pick.issues[0].date)}
</div>
<button onClick={() => setPick(null)} style={{ marginTop: 12, background: "none", border: 0, padding: 0, color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Choose a different garment</button>
</div>
<MSection label="How many are coming back?" right={`${pick.qty} out`} />
<div style={{ display: "flex", alignItems: "center", gap: 16, padding: 16, borderBottom: "1px solid var(--color-divider)" }}>
<span style={{ flex: 1, fontSize: 14, color: "var(--color-neutral-700)", lineHeight: 1.55 }}>
{pick.qty === 1
? "One is out, so this is it."
: `${pick.qty} are out. Count what is on the counter — the rest stays on ${st.first}s record.`}
</span>
<MStepper n={qty} onChange={setQty} min={1} max={pick.qty} />
</div>
<MSection label="Condition" />
<div style={{ padding: 16, display: "grid", gap: 8 }}>
{CONDITIONS.map(([value, title, note]) => {
const on = cond === value;
return (
<button key={value} onClick={() => setCond(value)} aria-pressed={on}
style={{ textAlign: "left", padding: "16px 18px", minHeight: 64, border: "2px solid " + INK, background: on ? INK : "transparent", color: on ? "var(--color-bg)" : INK, cursor: "pointer" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 17, letterSpacing: "-0.01em" }}>{title}</div>
<div style={{ fontSize: 13, marginTop: 4, color: on ? "var(--color-neutral-400)" : "var(--color-neutral-600)" }}>{note}</div>
</button>
);
})}
</div>
</>
) : (
<>
<MSection label="What is coming back?" right={`${held.reduce((t, h) => t + h.qty, 0)} out`} />
{held.length === 0
? <MEmpty title="Nothing to return" sub={`${staffName(st)} has no garments out at the moment.`} />
: held.map((h) => <MRow key={h.key} onClick={() => choose(h)} mark="ink" title={h.name} sub={`${h.qty} held · issued ${fmtDate(h.issues[0].date)}`} />)}
{/* A return has to match a record they hold, which is why the scan bar below is off when
nothing is out — so don't invite a scan the bar then refuses. */}
{held.length > 0 && <div style={{ padding: "18px 16px", fontSize: 14, color: "var(--color-neutral-700)" }}>Or scan the garment.</div>}
</>
)}
</MBody>
{pick
? <MBar label={busy ? "Recording…" : qty === 1 ? "Confirm return" : `Confirm return of ${qty}`} glyph="check" onClick={confirm} disabled={busy} />
: <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)} />}
</>
);
}