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

148 lines
7.6 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";
/* 1B — My kit.
*
* The linen room's record of what this person holds, shown to the person it is about, and a way
* to say it is wrong. "This isn't right" is deliberately blunt and deliberately last: the record
* is usually correct, and a dispute button placed first would invite one before anybody had read
* the list.
*
* The bar is a screen-level one, drawn on ALL THREE segments rather than only on Holding. A wrong
* recorded *size* is one of the commonest things anybody queries, and that is read on My sizes —
* where the screen literally says "tell them below", a promise only kept while the bar is there.
*/
import { useState } from "react";
import { useRouter } from "next/navigation";
import { MBody, MEmpty, MError, MRow, MRule, MTop } from "@/components/m";
import { INK, N600, SecondaryBar, Segments, SlipCard } from "@/components/staffui";
import StaffNav from "@/components/staffnav";
import { useDraft, useStaff } from "@/lib/staffclient";
import { fmtDate } from "@/lib/compute";
type Held = { itemId: string; item: string; size: string; si: number; qty: number; last: string };
/* One row per garment and size with its quantity — the slip as it was signed, not one repeated row
* per garment. The grouping is done in kitData(); the screen only draws it. */
type Slip = { id: string; date: string; lines: { item: string; size: string; qty: number }[]; signed: boolean };
type Data = { held: Held[]; total: number; handedBackThisYear: number; fyFrom: string; sizes: { top: string; pants: string }; slips?: Slip[] };
export default function KitScreen({ data }: { data: Data }) {
const { mutate, busy } = useStaff();
const router = useRouter();
const [tab, setTab] = useState<"holding" | "sizes" | "slips">("holding");
const [disputing, setDisputing] = useState(false);
// Kept across a dropped send and a screen change: ward wifi drops mid-sentence, and the one thing
// worse than a complaint that did not send is a complaint that did not send and is gone.
const draft = useDraft("dispute");
const [err, setErr] = useState("");
const [sent, setSent] = useState(false);
const slips = data.slips || [];
return (
<>
<MTop title="My kit" />
<MRule />
<MBody>
<Segments
label="What to show"
value={tab}
onPick={setTab}
options={[{ key: "holding" as const, label: "Holding" }, { key: "sizes" as const, label: "My sizes" }, { key: "slips" as const, label: "Slips" }]}
/>
{tab === "holding" && (
<div style={{ padding: "0 16px" }}>
{data.held.length === 0 ? (
<MEmpty title="Nothing on your record" sub="Anything the linen room issues you shows up here." />
) : (
data.held.map((h) => (
<MRow
key={`${h.itemId}:${h.si}`}
title={`${h.item}${h.size}`}
sub={`Last issued ${fmtDate(h.last)}`}
right={`×${h.qty}`}
/>
))
)}
{/* A row, not the paragraph this used to be: it is one figure about their own record,
and it reads as one line beside the garments it belongs with. */}
<MRow title="Handed back this year" right={String(data.handedBackThisYear)} />
</div>
)}
{tab === "sizes" && (
<div style={{ padding: "0 16px" }}>
<MRow title="Top" right={data.sizes.top || "not recorded"} />
<MRow title="Trouser" right={data.sizes.pants || "not recorded"} />
<div style={{ padding: "18px 0 6px", fontSize: 14, color: N600, lineHeight: 1.5 }}>
The linen room records these. Tell them below if they are wrong.
</div>
{/* Swapping a size is a request like any other, so it goes to the request screen rather
than living as its own flow. This is the door it is reached by. */}
<MRow title="Swap a size" sub="Hand one back, ask for another" href="/my/request?swap=1" chev />
</div>
)}
{tab === "slips" && (
slips.length === 0 ? (
<div style={{ padding: "0 16px" }}>
<MEmpty title="No signed slips" sub="A slip arrives when you sign for a hand-over at the counter." />
</div>
) : (
slips.map((sl) => (
<div key={sl.id}>
<SlipCard
date={fmtDate(sl.date)}
lines={sl.lines}
sigSrc={sl.signed ? `/api/staff/slip/${encodeURIComponent(sl.id)}/sig` : undefined}
/>
{/* A slip with no signature stored is not a broken screen, and saying so is kinder
than an empty space where a signature obviously belongs. */}
{!sl.signed && (
<div style={{ padding: "0 16px 14px", background: "#fff", fontSize: 12.5, color: N600 }}>
Handed over at the counter · no signature on file
</div>
)}
</div>
))
)
)}
{sent && (
<div style={{ margin: 16, background: INK, color: "var(--color-bg)", padding: 16, fontSize: 14, lineHeight: 1.5 }}>
Sent. The linen room will look at your record and come back to you.
</div>
)}
{disputing && !sent && (
<div style={{ padding: 16, borderTop: "2px solid " + INK }}>
<label htmlFor="tc-dispute" style={{ display: "block", fontSize: 13, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase" }}>What doesnt look right?</label>
<textarea
id="tc-dispute"
value={draft.value} onChange={(e) => { draft.set(e.target.value); setErr(""); }} rows={4}
placeholder="e.g. I handed two tunics back in August but theyre still on here"
style={{ width: "100%", minHeight: 84, marginTop: 12, padding: 12, border: "2px solid var(--color-divider)", borderRadius: 0, font: "inherit", fontSize: 15, resize: "none", background: "#fff", color: INK }}
/>
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ display: "flex", gap: 10, marginTop: 12, flexWrap: "wrap" }}>
<button
disabled={busy || !draft.value.trim()}
onClick={async () => {
const r = await mutate("dispute.raise", { body: draft.value });
if (!r.ok) { setErr(r.error); return; }
setSent(true); setDisputing(false); draft.clear(); router.refresh();
}}
style={{ minHeight: 48, padding: "0 20px", background: "var(--color-accent)", color: "#fff", border: 0, borderRadius: 0, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: busy ? "wait" : "pointer", opacity: busy || !draft.value.trim() ? 0.45 : 1 }}
>{busy ? "Sending…" : "Send to the linen room"}</button>
<button onClick={() => { setDisputing(false); setErr(""); }}
style={{ minHeight: 48, padding: "0 20px", background: "transparent", color: INK, border: "2px solid " + INK, borderRadius: 0, font: "inherit", fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer" }}>Cancel</button>
</div>
</div>
)}
<div style={{ height: 12 }} />
</MBody>
{!disputing && !sent && <SecondaryBar label="This isnt right" onClick={() => setDisputing(true)} />}
<StaffNav />
</>
);
}