Files
threadcount-community/components/screens/Damage.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

190 lines
8.6 KiB
TypeScript

"use client";
/* Report damage. Two jobs on one screen: tell the linen room, and start the replacement.
*
* The two are deliberately separate acts. Reporting damage does not issue anything and does not
* silently remove the garment — it comes off the record when it is handed in at the counter. A
* screen that wrote off a garment on somebody's say-so would be a screen the linen room stops
* trusting, and a screen that quietly issued a replacement would route around the manager.
*
* `Contaminated` is not in the list. Clinically it is a different pathway — red bag, no return to
* the counter — and telling someone to carry a contaminated garment to the linen room would be
* worse than saying nothing. Wards use the route they already have.
*/
import { useState } from "react";
import { MBar, MBody, MChipRow, MError, MRule, MSwitchRow, MTop } from "@/components/m";
import { DarkCard, N600, N700, NumberedField, OptionList } from "@/components/staffui";
import Sent from "@/components/screens/Sent";
import { useStaff } from "@/lib/staffclient";
import { DAMAGE_KINDS } from "@/lib/staffreq";
type Holding = {
issueId: string; itemId: string; item: string; size: string; si: number; qty: number;
labelId: string; issued: string; replacement: string;
};
export default function DamageScreen({ holdings, managerName, notifyWays }: {
holdings: Holding[]; managerName: string; notifyWays: { email: boolean; push: boolean };
}) {
const { mutate, busy } = useStaff();
const [issueId, setIssueId] = useState<string | null>(null);
const [kind, setKind] = useState<string | null>(null);
const [note, setNote] = useState("");
/* The switch defaults on, because asking for a replacement is what almost everybody reporting a
* torn tunic actually wants — but only when there is somebody to ask. With no manager recorded
* the request half cannot be raised at all, so defaulting it on left people tapping "Report and
* request" and getting an error they could do nothing about. */
const canRequest = !!managerName;
const [replace, setReplace] = useState(canRequest);
const [err, setErr] = useState("");
/* The server's sentence for a report it saved without a replacement behind it — almost always a
* garment whose range has been withdrawn, which cannot be ordered but is still on somebody's
* back. Nothing failed, so it is not an error; but the screen used to go straight to the kit
* list on a plain success and the nurse walked away expecting a replacement nobody had ordered. */
const [noReplacement, setNoReplacement] = useState("");
/* Where a finished report lands. A replacement was raised, so this is the request screen's own
* Sent — same approver, same "what happens next" — or, with no replacement asked for, the same
* screen saying the one thing that is still true: it comes off the record at the counter. */
const [done, setDone] = useState<{ what: string; order: { id: string; code: string; manager: string; notified: boolean } | null } | null>(null);
const held = holdings.find((h) => h.issueId === issueId) || null;
const ready = !!held && !!kind;
if (noReplacement) {
return (
<>
<MTop title="Reported" />
<MRule />
<MBody>
<div style={{ padding: 16 }}>
<DarkCard kicker="Reported" title="No replacement has been ordered" meta={noReplacement} />
</div>
</MBody>
<MBar label="Back to your kit" href="/my/kit" />
</>
);
}
if (done) {
const o = done.order;
const who = o?.manager || managerName || "your manager";
return o ? (
<Sent
headline={`Sent to ${who}`}
sub={`${done.what} · ${o.code}`}
next={
notifyWays.push
? "You get a notification when it is approved, and again when it is ready."
: o.notified
? `${who} has been emailed.`
: `It is waiting with ${who}.`
}
actions={[
{ label: "Open the order", href: `/my/orders/${o.id}` },
{ label: "Back to home", href: "/my" },
]}
bar={{ label: "Back to home", href: "/my" }}
/>
) : (
<Sent
title="Reported"
headline="Reported to the linen room"
sub={done.what}
next="It comes off your record when you hand it in at the counter."
actions={[{ label: "Back to your kit", href: "/my/kit" }]}
bar={{ label: "Back to home", href: "/my" }}
/>
);
}
return (
<>
<MTop title="Report damage" back backHref="/my/kit" />
<MRule />
<MBody>
<NumberedField n={1} label="Which item" first>
{holdings.length === 0 ? (
<p style={{ fontSize: 14, color: N600, lineHeight: 1.6, margin: 0 }}>
Nothing on your record to report.
</p>
) : (
<OptionList
value={issueId}
onPick={(k) => { setIssueId(k); setErr(""); }}
options={holdings.map((h) => ({
key: h.issueId,
label: `${h.item}${h.size}`,
// The label id is what the linen room reads off the garment in their hand, so a
// row here can be matched to a physical thing.
meta: `${h.labelId} · issued ${h.issued}`,
}))}
/>
)}
</NumberedField>
{/* Both steps stay on screen from the start, as the mockup draws them. Revealing "what
happened" only after a garment is chosen hid half the job from somebody deciding whether
this screen was the one they wanted. */}
<NumberedField n={2} label="What happened">
<MChipRow
label="What happened"
value={kind}
onPick={(k) => { setKind(k); setErr(""); }}
options={DAMAGE_KINDS.map((d) => ({ value: d, label: d }))}
/>
<textarea
value={note} onChange={(e) => setNote(e.target.value)} rows={3}
aria-label="Anything the linen room should know (optional)"
placeholder="Anything the linen room should know (optional)"
style={{ width: "100%", minHeight: 84, marginTop: 14, padding: 12, border: "2px solid var(--color-divider)", borderRadius: 0, font: "inherit", fontSize: 16, resize: "none", background: "#fff", color: "var(--color-text)" }}
/>
</NumberedField>
<div style={{ padding: "16px 16px 0" }}>
<MSwitchRow
title="Ask for a replacement too"
sub={canRequest
? `Goes to ${managerName} with the report`
: "Nobody is recorded as your approver yet — ask the linen room to set your manager"}
on={replace && canRequest}
onToggle={() => setReplace((v) => !v)}
disabled={!canRequest}
/>
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, margin: "12px 0 0" }}>
The damaged item comes off your record when you hand it in at the counter.
</p>
</div>
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ height: 12 }} />
</MBody>
<MBar
label={busy ? "Sending…" : replace && canRequest ? "Report and request" : "Report it"}
disabled={!ready || busy}
offReason="Pick the item and what happened"
onClick={async () => {
if (!held || !kind) return;
/* `replacement` is the whole request.create result at run time — id, code, manager and
* whether an email actually left the server — because damage.report raises the
* replacement through that op and hands back what it returned. Every field past the id
* is optional here: the app in somebody's pocket can be older or newer than the server
* it is talking to, and a missing one only costs a line of the confirmation. */
const r = await mutate<{
replacement: { id: string; code?: string; manager?: string; notified?: boolean } | null;
replacementNote?: string;
}>("damage.report", { issueId: held.issueId, kind, note, replace: replace && canRequest });
if (!r.ok) { setErr(r.error); return; }
const why = (r.result.replacementNote || "").trim();
if (!r.result.replacement && why) { setNoReplacement(why); return; }
const rep = r.result.replacement;
setDone({
what: `${held.item}${held.size}`,
order: rep ? {
id: rep.id, code: rep.code || "", manager: rep.manager || managerName, notified: !!rep.notified,
} : null,
});
}}
/>
</>
);
}