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 e2d6d42 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -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't be shown again. They go to{" "}
|
||||
<b>{origin}/my</b>, choose “I have a code”, 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 hasn’t been used. Make a new one if they’ve 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user