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

223 lines
10 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";
/* Team ▸ Round — what the desk sees when the trolley arrives.
*
* Unclaimed bags from previous rounds sit **above** today's work. They are the linen room's
* biggest waste — a bag signed for on the ward and never collected is a garment out of stock and
* off the record — so the screen refuses to bury them under whatever arrived this morning.
*
* Anyone on the ward can sign, and whoever does is named on the requester's order. That is the
* whole audit story: a missing bag has a name against it.
*
* Signing for the lot now reports itself. It used to loop silently: on a ward where the fourth bag
* was refused, the clerk saw an unchanged list and no idea which three had gone through, so the
* honest thing — a count that climbs, and the name of whatever stopped it — is on the screen.
*/
import { useState } from "react";
import { MEmpty, MError } from "@/components/m";
import {
ACCENT_300, DoneRow, EdgeRow, GROUND, INK, Kicker, N300, N600, Progress, SecondaryBar, lineText,
} from "@/components/staffui";
import Team, { Band, ChipAction, ChipRow } from "./Team";
import { useStaff } from "@/lib/staffclient";
import { statusText } from "@/lib/staffreq";
import type { ReqLine, ReqRow } from "@/lib/staffdata";
import { formatInZone } from "@/lib/compute";
type Bag = {
id: string; code: string; subjectName: string;
/** What is actually in the bag: the approved lines, and nothing the manager knocked back. */
lines: ReqLine[]; summary: string; garments: number; lineCount: number;
status: string; signerName: string | null; signedAt: string | null; claimedAt: string | null;
since: string;
};
export default function RoundScreen({ ward, toSign, unclaimed, signedToday, raised }: {
ward: string; toSign: Bag[]; unclaimed: Bag[]; signedToday: Bag[];
/** Open requests this person raised for someone else, however they came to raise them. */
raised: ReqRow[];
}) {
const { mutate, busy, me } = useStaff();
/* `signedAt` arrives as a UTC instant, and both of the places it is shown used to read it as
* local text. The ward round happens in the morning, which is exactly when the UTC date is still
* yesterday's, so the desk was routinely told a bag it signed for an hour ago went out the day
* before. The facility's zone answers both. */
const tz = me.tz;
const [err, setErr] = useState("");
const [prog, setProg] = useState<{ done: number; total: number } | null>(null);
async function sign(id: string) {
setErr("");
const r = await mutate("round.sign", { id });
if (!r.ok) setErr(r.error);
}
async function claim(id: string) {
setErr("");
const r = await mutate("round.claim", { id });
if (!r.ok) setErr(r.error);
}
/* One call per bag, because that is what signing is — a signature against one hand-over — and the
* count climbs after each. It stops at the first refusal and says which bag it stopped on: the
* ones already signed are done and must not be sent again (round.sign is not idempotent), and
* the rest are left exactly as they were for the clerk to deal with. */
async function signAll() {
const list = toSign;
if (!list.length) return;
setErr("");
setProg({ done: 0, total: list.length });
let done = 0;
for (const b of list) {
const r = await mutate("round.sign", { id: b.id });
if (!r.ok) {
setErr(`Signed ${done} of ${list.length}${b.subjectName}: ${r.error}`);
setProg(null);
return;
}
done += 1;
setProg({ done, total: list.length });
}
setProg(null);
}
const nothingAtAll = toSign.length === 0 && signedToday.length === 0 && unclaimed.length === 0;
return (
<Team
active="/my/round"
foot={toSign.length > 1 ? (
<div style={{ borderTop: "2px solid " + INK }}>
{/* Bulk signing is allowed but not encouraged — a secondary action, never the red one. */}
<SecondaryBar
label={prog ? "Signing…" : `Sign for all ${toSign.length} remaining`}
disabled={busy || !!prog}
onClick={() => void signAll()}
/>
</div>
) : undefined}
>
<div style={{ padding: "4px 16px 0" }}><Kicker>Ward round</Kicker></div>
<div style={{ background: INK, color: GROUND, padding: 18, margin: "8px 0 0", display: "flex", alignItems: "center", gap: 16 }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 34, letterSpacing: "-0.03em", lineHeight: 1 }}>
{toSign.length}
</div>
<div style={{ flex: 1, fontSize: 14, lineHeight: 1.4, color: N300 }}>
<div>{toSign.length === 1 ? "bag to sign" : "bags to sign"}</div>
<div>{toSign.length === 0 ? "nothing waiting" : "arriving today"}</div>
</div>
{signedToday.length > 0 && (
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300 }}>
{signedToday.length} signed
</div>
)}
</div>
{prog && <Progress label="Signing" done={prog.done} total={prog.total} unit="signed" />}
<MError msg={err} onDismiss={() => setErr("")} />
{unclaimed.length > 0 && (
<>
<Band tone="attention" label="Unclaimed from earlier rounds" />
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
{unclaimed.map((b) => (
<EdgeRow key={b.id} tone="accent">
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{b.subjectName}</div>
<div style={{ fontSize: 13, color: N600, marginTop: 4, lineHeight: 1.4 }}>
{[b.lines.map(lineText).join(", "), b.signedAt ? `since ${formatInZone(b.signedAt, tz, { weekday: "long" })}` : ""].filter(Boolean).join(" · ")}
</div>
<ChipRow>
{/* Nudging only works if the requester eventually opens the app; often the bag
went days ago and the person who knows that is the clerk standing where it
used to be, which is what Collected is for. */}
<ChipAction label="Nudge" href={`/my/orders/${b.id}/messages`} />
<ChipAction label="Collected" disabled={busy} onClick={() => void claim(b.id)} />
</ChipRow>
</EdgeRow>
))}
</div>
</>
)}
{/* The count stays on the band when it reaches nought: a heading that drops its note the
moment the work is done reads as a screen that has lost its place. */}
<Band label="Arriving today" right={`${toSign.length} to sign`} />
{nothingAtAll ? (
<div style={{ padding: "0 16px" }}>
<MEmpty title={`Nothing on the round for ${ward || "your ward"} right now.`} />
</div>
) : (
<>
{toSign.map((b) => (
<EdgeRow key={b.id} tone="ink">
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{b.subjectName}</div>
{/* Signing is a signature: whoever puts their name to a bag of four garments should be
able to read the four before they do, rather than a count of them and a code. That
is why the garments themselves are the row's second line. Nothing declined is
listed — a knocked-back garment never reaches the trolley. */}
<div style={{ fontSize: 13, color: N600, marginTop: 4, lineHeight: 1.4 }}>
{b.lines.map(lineText).join(", ")}
</div>
<ChipRow>
<ChipAction label="Sign" disabled={busy || !!prog} onClick={() => void sign(b.id)} />
</ChipRow>
</EdgeRow>
))}
{signedToday.map((b) => (
<DoneRow key={b.id}>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{b.subjectName}</div>
<div style={{ fontSize: 13, marginTop: 3 }}>
Signed {b.signedAt ? formatInZone(b.signedAt, tz, { hour: "2-digit", minute: "2-digit", hour12: false }) : ""} by {b.signerName}
</div>
</div>
<span style={{
fontSize: 12, fontWeight: 800, letterSpacing: "0.05em", textTransform: "uppercase",
background: "var(--color-divider)", color: INK, padding: "5px 8px", whiteSpace: "nowrap",
}}>Done</span>
</div>
</DoneRow>
))}
{/* Under the signed rows rather than instead of them: the desk wants this morning's work
still on the screen AND to be told it is finished. Drawn whenever nothing is left to
sign, which is what the mockup does once the last bag has a name against it. */}
{toSign.length === 0 && (
<div style={{ padding: "0 16px" }}>
<MEmpty title="Every bag is signed" sub="Whoever signs appears on the requesters order." />
</div>
)}
</>
)}
{/* What this person raised in somebody else's name, still on its way. The bags above are only
the ones arriving today; a request typed in on Tuesday and approved on Thursday is invisible
there until it turns up on a trolley, so without this the only way to find out where it had
got to was to ring the linen room. */}
{raised.length > 0 && (
<>
<Band label="Raised by you · still open" />
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
{raised.map((r) => {
const st = statusText(r, { mine: false, first: r.subjectName?.split(" ")[0] });
return (
<EdgeRow key={r.id} tone="divider" href={`/my/orders/${r.id}`}>
<div style={{ display: "flex", gap: 12, alignItems: "baseline" }}>
<span style={{ flex: 1, fontSize: 16, fontWeight: 800 }}>{r.subjectName}</span>
<span style={{ fontSize: 12, color: N600 }}>{r.code}</span>
</div>
<div style={{ fontSize: 14.5, fontWeight: 600, marginTop: 5, lineHeight: 1.35 }}>{r.summary}</div>
<div style={{ fontSize: 13, color: N600, marginTop: 4 }}>
{[st.label, st.note].filter(Boolean).join(" · ")}
</div>
</EdgeRow>
);
})}
</div>
</>
)}
<div style={{ height: 12 }} />
</Team>
);
}