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
+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>
</>
);
}