Files
threadcount-community/components/screens/KitCheck.tsx
T
ThreadCount f72f0626b0
Release / release (push) Has been skipped
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 794bab5 on 2026-09-16. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-17 05:49:16 +10:00

187 lines
8.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/* 2A — Kit check. Every few months, reconcile the record with reality, item by item.
*
* "Nothing here is chargeable" is not reassurance for its own sake — a check that felt like an
* audit would be answered with whatever number keeps somebody out of trouble, and the resulting
* data would be worse than no data.
*
* Writing off and re-requesting stay separate acts. Saying a garment is missing does not quietly
* order another one: that would turn an honest answer into a request somebody's manager has to
* decline, which is exactly how you teach a ward to stop answering honestly.
*
* Between rounds this is a STATE, not a refusal. A refusal explains nothing by design, and "no kit
* check is open" is not a secret — it is the answer somebody who tapped a notification from last
* autumn needs, with the one way onward under it.
*/
import { useState } from "react";
import { MBar, MBody, MEmpty, MError, MONO, MRule, MTop } from "@/components/m";
import { ACCENT_300, DIVIDER, GROUND, INK, N300, N600, N700 } from "@/components/staffui";
import { useStaff } from "@/lib/staffclient";
import { fmtDate } from "@/lib/compute";
type Row = { itemId: string; item: string; size: string; si: number; onRecord: number; answered: number | null };
export default function KitCheckScreen({ closed, dueBy = "", lastConfirmed = "", rows = [] }: {
/** No cycle open. The screen still exists; it just has one thing to say. */
closed?: boolean;
dueBy?: string; lastConfirmed?: string; rows?: Row[];
}) {
const { mutate, busy } = useStaff();
const [answers, setAnswers] = useState<Record<string, number>>(
Object.fromEntries(rows.filter((r) => r.answered !== null).map((r) => [`${r.itemId}:${r.si}`, r.answered as number])),
);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [err, setErr] = useState("");
const [done, setDone] = useState(false);
const answeredCount = rows.filter((r) => answers[`${r.itemId}:${r.si}`] !== undefined).length;
async function answer(r: Row, confirmed: number) {
const k = `${r.itemId}:${r.si}`;
setAnswers((a) => ({ ...a, [k]: confirmed }));
setErr("");
const res = await mutate("kit.answer", { itemId: r.itemId, si: r.si, onRecord: r.onRecord, confirmed });
if (!res.ok) {
setErr(res.error);
setAnswers((a) => { const n = { ...a }; delete n[k]; return n; });
}
}
// aria-pressed, because the only other thing separating the chosen answer from the unchosen one
// is an ink fill: nothing a screen reader can hear, and nothing at all in high contrast.
const pick = (on: boolean, label: string, onClick: () => void): React.ReactNode => (
<button onClick={onClick} aria-pressed={on} style={{
flex: 1, minWidth: 0, minHeight: 44, borderRadius: 0, font: "inherit",
border: `2px solid ${on ? INK : DIVIDER}`,
background: on ? INK : "#fff", color: on ? GROUND : N700,
fontWeight: 700, fontSize: 14, cursor: "pointer",
}}>{label}</button>
);
if (closed) {
return (
<>
<MTop title="Kit check" back backHref="/my" />
<MRule />
<MBody>
<div style={{ padding: "0 16px" }}>
<MEmpty title="No kit check is open" sub="The linen room opens one every few months. You get a notification." />
</div>
</MBody>
<MBar label="Back to home" href="/my" />
</>
);
}
if (done) {
return (
<>
<MTop title="Kit check" back backHref="/my" />
<MRule />
<MBody>
<div style={{ background: INK, color: GROUND, padding: 16 }}>
<div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: ACCENT_300 }}>Thanks</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 900, fontSize: 22, lineHeight: 1.15, marginTop: 4 }}>
Thats your record confirmed.
</div>
<div style={{ fontSize: 13, lineHeight: 1.5, color: N300, marginTop: 4 }}>
The linen room will square anything short. Nothing is charged.
</div>
</div>
</MBody>
<MBar label="Back to home" href="/my" />
</>
);
}
return (
<>
<MTop title="Kit check" back backHref="/my" right={dueBy ? `Due ${fmtDate(dueBy)}` : undefined} />
<MRule />
<MBody>
{/* The ink header carries the question and the one rule that decides how honestly it gets
answered. Nobody keeps their uniform at work — it is at home, in the wash, in a bag in
the boot — so the ask is to count what they still have, wherever it is. */}
<div style={{ background: INK, color: GROUND, padding: "14px 16px 16px" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 900, fontSize: 22, letterSpacing: "-0.015em", lineHeight: 1.05 }}>
Have you still got everything on your record?
</div>
<div style={{ fontSize: 13, color: "#d6d3d2", marginTop: 4, lineHeight: 1.45 }}>
Count everything you still have, wherever it is. Nothing here is chargeable.
{lastConfirmed ? ` Last confirmed ${fmtDate(lastConfirmed)}.` : ""}
</div>
</div>
<MError msg={err} onDismiss={() => setErr("")} />
{rows.length === 0 ? (
<div style={{ padding: "0 16px" }}>
<MEmpty title="Nothing on your record to check." sub="Anything the linen room issues you shows up here." />
</div>
) : (
<div style={{ padding: "0 16px" }}>
{rows.map((r) => {
const k = `${r.itemId}:${r.si}`;
const a = answers[k];
const short = a !== undefined && a < r.onRecord;
const open = expanded[k];
return (
<div key={k} style={{
borderBottom: `1px solid ${DIVIDER}`, padding: "10px 0 12px",
...(short ? { borderLeft: "6px solid var(--color-accent)", paddingLeft: 12, marginLeft: -12 } : null),
}}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 10, alignItems: "baseline" }}>
<span style={{ fontSize: 15, fontWeight: 700 }}>{r.item} {r.size}</span>
<span style={{ fontFamily: MONO, fontSize: 14 }} aria-label={`${r.onRecord} on record`}>×{r.onRecord}</span>
</div>
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
{/* "All 3 here" was an answer only somebody standing in front of the garments
could give, and nobody is: they are at home, half of them in the wash. */}
{pick(a === r.onRecord, `Got all ${r.onRecord}`, () => { setExpanded((e) => ({ ...e, [k]: false })); void answer(r, r.onRecord); })}
{pick(short || !!open, short ? (a === 0 ? "None left" : `Only ${a}`) : "Fewer", () => setExpanded((e) => ({ ...e, [k]: !e[k] })))}
</div>
{open && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
{Array.from({ length: r.onRecord }, (_, i) => i).map((n) => (
<button key={n} aria-pressed={a === n} onClick={() => { void answer(r, n); setExpanded((e) => ({ ...e, [k]: false })); }} style={{
minHeight: 46, minWidth: 50, padding: "0 12px", borderRadius: 0, font: "inherit",
border: `2px solid ${a === n ? INK : DIVIDER}`,
background: a === n ? INK : "#fff", color: a === n ? GROUND : N700,
fontWeight: 700, fontSize: 14, cursor: "pointer",
}}>{n === 0 ? "None left" : `Only ${n}`}</button>
))}
</div>
)}
{short && (
/* This used to say the missing ones "come off your record". They don't — the
answer is written down and that is all it does; only the linen room can
change the record. Telling somebody it has already been corrected, when it
hasn't, is how they stop believing the next thing this screen says. */
<div style={{ fontSize: 13, lineHeight: 1.5, color: N700, marginTop: 8 }}>
{r.onRecord - (a as number)} unaccounted for. The linen room will square your record.
</div>
)}
</div>
);
})}
</div>
)}
<div style={{ height: 12 }} />
</MBody>
{rows.length === 0 ? (
<MBar label="Back to home" href="/my" />
) : (
<MBar
label={busy ? "Saving…" : "Confirm"}
small={`${answeredCount} of ${rows.length} answered`}
disabled={busy || answeredCount < rows.length}
onClick={() => setDone(true)}
/>
)}
</>
);
}