1bc2de655a
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
118 lines
6.8 KiB
TypeScript
118 lines
6.8 KiB
TypeScript
"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>
|
||
</>
|
||
);
|
||
}
|