Files
threadcount-community/components/requests/RequestList.tsx
T
ThreadCount 8c86c988c1 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 2d04e45 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-16 04:04:31 +10:00

522 lines
28 KiB
TypeScript
Raw 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";
/* The request queue, as one reusable list.
*
* Imported by /app/requests (the full queue), the staff record's Requests tab and Today's Pick
* group; the command panel reads useRequests(). `lines` is what was asked, `bag` is what is
* picked: the pick, the slip and the collection code are always built from `bag`, so a garment
* the ward declined never goes in somebody's hands. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useSnap } from "@/lib/client";
import { openSlip } from "@/components/dialogs";
import { Empty, ErrorLine } from "@/components/ui";
import { Panel, Tag } from "@/components/portal";
import { formatInZone, genderLabel, key, onhand, slipLive, type Ledger, type Snapshot } from "@/lib/compute";
import type { ReqLine } from "@/lib/staffdata";
import { NEEDS_STAFF, OPEN_REQUEST, statusText } from "@/lib/staffreq";
export type RequestMessage = { id: string; fromStaff: boolean; authorName: string; body: string; at: string };
export type RequestEvent = { id: string; label: string; meta: string; actorName: string; at: string };
/** One request exactly as GET /api/requests returns it (the API does not change). */
export type RequestRow = {
id: string; code: string; status: string; staffId: string; staffName: string; staffNum: string; ward: string;
lines: ReqLine[]; bag: ReqLine[];
summary: string; garments: number; lineCount: number; decision: string | null;
reason: string; note: string;
managerName: string; managerId: string | null;
declineReason: string | null; route: string | null;
collectCode: string | null; holdUntil: string; signerName: string | null; signerRole: string | null;
signedAt: string | null; claimedAt: string | null;
raisedById: string | null; raisedByName: string;
createdAt: string; decidedAt: string | null;
messages: RequestMessage[]; events: RequestEvent[];
};
export type DisputeRow = { id: string; body: string; staffName: string; staffNum: string; ward: string; at: string };
export type CycleRow = { id: string; dueBy: string; openedBy: string; openedAt: string; answers: number };
export type WaitingRow = { id: string; staffName: string; staffNum: string; ward: string; item: string; size: string; since: string; offeredAt: string | null };
export type DamageRow = { id: string; kind: string; note: string; photoId: string | null; staffId: string; staffName: string; staffNum: string; ward: string; item: string; size: string; requestCode: string; at: string };
export type ShortfallRow = { id: string; staffId: string; staffName: string; staffNum: string; ward: string; item: string; size: string; onRecord: number; confirmed: number; short: number; at: string };
export type RequestsPayload = {
requests: RequestRow[]; requestLimit: number; moreRequests: boolean;
/** Absent when fetched with ?staff= */
disputes?: DisputeRow[]; cycle?: CycleRow | null; shortfalls?: ShortfallRow[]; waiting?: WaitingRow[]; damage?: DamageRow[];
};
export const REQUEST_FILTERS = ["todo", "noapprover", "open", "all", "queries", "damage", "cycles"] as const;
export type RequestFilter = (typeof REQUEST_FILTERS)[number];
export type RequestRowFilter = Extract<RequestFilter, "todo" | "noapprover" | "open" | "all">;
export const REQUEST_FILTER_LABEL: Record<RequestFilter, string> = {
todo: "To do", noapprover: "Needs an approver", open: "Open", all: "All",
queries: "Record queries", damage: "Damage", cycles: "Kit check & waitlist",
};
export const isRowFilter = (f: RequestFilter): f is RequestRowFilter => f === "todo" || f === "noapprover" || f === "open" || f === "all";
/* ---------- status predicates: the only definitions screens may use ---------- */
const TODO = new Set(["accepted", "picking", "ready", "round"]);
export function isTodo(r: RequestRow): boolean { return TODO.has(r.status); }
/** Nobody was ever asked to approve it, so nobody on the ward can move it. */
export function isStranded(r: RequestRow): boolean { return r.status === "awaiting" && !r.managerName; }
export function isOpenRequest(r: RequestRow): boolean { return OPEN_REQUEST.has(r.status as never); }
export function isPickable(r: RequestRow): boolean { return r.status === "accepted"; }
type Scope = { staffId?: string; ward?: string };
const inScope = (r: { staffId?: string; ward: string }, scope?: Scope) =>
(!scope?.staffId || r.staffId === scope.staffId) && (!scope?.ward || r.ward === scope.ward);
export function requestsFor(rows: readonly RequestRow[], f: RequestRowFilter, scope?: Scope): RequestRow[] {
const pick = f === "todo" ? isTodo : f === "noapprover" ? isStranded : f === "open" ? isOpenRequest : () => true;
return rows.filter((r) => inScope(r, scope) && pick(r));
}
/** The payload narrowed to one person or one ward. Record queries and the waitlist carry no staff
* id, so a person is matched on their staff number there: pass it when you have the snapshot,
* otherwise it is read off that person's own requests, damage or shortfall rows. */
export function scopePayload(p: RequestsPayload, scope?: Scope, staffNum?: string): RequestsPayload {
if (!scope?.staffId && !scope?.ward) return p;
const num = scope.staffId
? staffNum ?? [...p.requests, ...(p.damage ?? []), ...(p.shortfalls ?? [])].find((r) => r.staffId === scope.staffId)?.staffNum
: undefined;
const byNum = (r: { staffNum: string; ward: string }) =>
(!scope.staffId || (!!num && r.staffNum === num)) && (!scope.ward || r.ward === scope.ward);
return {
...p,
requests: p.requests.filter((r) => inScope(r, scope)),
disputes: p.disputes?.filter(byNum),
waiting: p.waiting?.filter(byNum),
damage: p.damage?.filter((r) => inScope(r, scope)),
shortfalls: p.shortfalls?.filter((r) => inScope(r, scope)),
};
}
export function requestCounts(p: RequestsPayload, scope?: Scope): Record<RequestFilter, number> {
const q = scopePayload(p, scope);
return {
todo: q.requests.filter(isTodo).length,
noapprover: q.requests.filter(isStranded).length,
open: q.requests.filter(isOpenRequest).length,
all: q.requests.length,
queries: q.disputes?.length ?? 0,
damage: q.damage?.length ?? 0,
cycles: q.waiting?.length ?? 0,
};
}
/** Shelf check of a request's bag: inStock when every bag line has onhand(itemId:si) >= qty. */
export function bagStock(s: Snapshot, L: Ledger, r: RequestRow): { inStock: boolean; shortLines: ReqLine[] } {
// Two lines for the same garment and size draw on the same shelf, so they are summed first.
const want: Record<string, number> = {};
for (const l of r.bag) want[key(l.itemId, l.si)] = (want[key(l.itemId, l.si)] || 0) + l.qty;
const shortLines = r.bag.filter((l) => onhand(s, L, key(l.itemId, l.si)) < want[key(l.itemId, l.si)]);
return { inStock: shortLines.length === 0, shortLines };
}
const lineText = (l: ReqLine) => `${l.qty} × ${l.item}${l.gender && l.gender !== "Unisex" ? ` (${genderLabel(l.gender)})` : ""}${l.size}`;
/** The slip payload for openSlip() (bag lines only, collection code, cut). */
export function requestSlip(s: Snapshot, r: RequestRow): Record<string, string | number> {
return {
staffName: r.staffName, dept: r.ward, deliverTo: r.ward,
sets: r.garments, po: r.code, code: r.collectCode || "",
lines: r.bag.map(lineText).join("\n"),
dateReceived: s.today, requestedBy: r.staffNum,
deliveredBy: s.settings.coordinator, dateTime: s.today,
};
}
/* ---------- fetching: one module-level cache per key, shared by every screen ---------- */
type Entry = { at: number; data: RequestsPayload | null; error: string; loading: boolean; inflight: Promise<void> | null };
const TTL = 60_000;
const cache = new Map<string, Entry>();
const listeners = new Map<string, Set<() => void>>();
const entryOf = (k: string): Entry => {
let e = cache.get(k);
if (!e) { e = { at: 0, data: null, error: "", loading: false, inflight: null }; cache.set(k, e); }
return e;
};
const notify = (k: string) => listeners.get(k)?.forEach((fn) => fn());
function load(k: string, force: boolean): Promise<void> {
const e = entryOf(k);
if (e.inflight) return e.inflight;
if (!force && e.data && Date.now() - e.at < TTL) return Promise.resolve();
e.loading = true;
notify(k);
e.inflight = (async () => {
try {
const r = await fetch(k ? `/api/requests?staff=${encodeURIComponent(k)}` : "/api/requests");
if (!r.ok) {
const j = await r.json().catch(() => ({}));
e.error = (j && j.error) || "Couldnt load the requests.";
} else {
e.data = await r.json();
e.error = "";
e.at = Date.now();
}
} catch {
e.error = "Couldnt reach the server — requests arent loaded.";
} finally {
e.loading = false;
e.inflight = null;
notify(k);
}
})();
return e.inflight;
}
/** Fetch hook. Unscoped = the whole queue plus disputes/cycle/waiting/damage; staffId = GET /api/requests?staff=<id>.
* Results are cached per key at module level for 60s; reload() refetches. Errors are returned, never thrown. */
export function useRequests(opts?: { staffId?: string; enabled?: boolean }): { data: RequestsPayload | null; error: string; loading: boolean; reload: () => Promise<void> } {
const k = opts?.staffId || "";
const enabled = opts?.enabled !== false;
const [, bump] = useState(0);
useEffect(() => {
if (!enabled) return;
let set = listeners.get(k);
if (!set) { set = new Set(); listeners.set(k, set); }
const subs = set;
const fn = () => bump((n) => n + 1);
subs.add(fn);
void load(k, false);
return () => { subs.delete(fn); };
}, [k, enabled]);
const reload = useCallback(() => load(k, true), [k]);
if (!enabled) return { data: null, error: "", loading: false, reload };
const e = entryOf(k);
return { data: e.data, error: e.error, loading: e.loading || (!e.data && !e.error), reload };
}
type QueueOp = "request.pick" | "request.hold" | "request.round" | "request.collected" | "request.reply" | "request.reassign" | "request.withdraw" | "damage.handedIn" | "dispute.resolve" | "kitcheck.open" | "kitcheck.close" | "waitlist.offer";
/** Runs a request-queue op through mutate(), then reload(). Returns ok. */
export function useRequestActions(reload: () => Promise<void>): { act: (op: QueueOp, payload: Record<string, unknown>) => Promise<boolean>; error: string; clearError: () => void } {
const { mutate } = useSnap();
const [error, setError] = useState("");
const act = useCallback(async (op: QueueOp, payload: Record<string, unknown>) => {
setError("");
const r = await mutate(op, payload);
if (!r.ok) { setError(r.error); return false; }
// Every screen sharing this cache key sees the change, not just the one that made it.
await reload();
return true;
}, [mutate, reload]);
const clearError = useCallback(() => setError(""), []);
return { act, error, clearError };
}
/* ---------- the list ---------- */
export type RequestListProps = {
/** Scope. staffId fetches ?staff=<id> unless `data` is passed; ward filters client-side on r.ward. */
staffId?: string;
ward?: string;
/** Which rows. Default "open". */
filter?: RequestRowFilter;
/** Row expanded on first render (from ?open=). */
openId?: string | null;
/** Hide the person/ward text on each row (every row is the same person). */
hidePerson?: boolean;
/** Panel title; null renders rows without panel chrome. Default REQUEST_FILTER_LABEL[filter]. */
title?: React.ReactNode | null;
/** One-sentence empty state. Default per filter. */
emptyText?: string;
/** Called whenever data loads or changes. */
onCounts?: (counts: Record<RequestFilter, number>) => void;
/** Use an already-loaded payload (the /app/requests page shares one hook between its segment and the list). */
data?: RequestsPayload | null;
reload?: () => Promise<void>;
};
const EMPTY: Record<RequestRowFilter, string> = {
todo: "Nothing approved and waiting.",
noapprover: "Every waiting request has somebody to approve it.",
open: "No open requests.",
all: "No requests yet.",
};
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
/* Row layout rules. Kept with the component (and hoisted once by React) because only the list
* wears them; the mobile rule stretches an expanded row's actions to full width. */
/** The row styles, rendered once however many lists are on screen. */
export function RequestStyles() {
return null; // the rules are in app/globals.css under portal redesign
}
export default function RequestList({ staffId, ward, filter = "open", openId, hidePerson, title, emptyText, onCounts, data: given, reload: givenReload }: RequestListProps) {
const external = given !== undefined;
const own = useRequests({ staffId, enabled: !external });
const data = external ? given : own.data;
const reload = external ? givenReload ?? own.reload : own.reload;
const { act, error } = useRequestActions(reload);
const [openRow, setOpenRow] = useState<string | null>(openId ?? null);
useEffect(() => { if (openId) setOpenRow(openId); }, [openId]);
// Bring a deep-linked row into view once it is on screen.
const scrolled = useRef<string | null>(null);
useEffect(() => {
if (!openId || scrolled.current === openId || !data) return;
const el = document.getElementById(`req-${openId}`);
if (el) { scrolled.current = openId; el.scrollIntoView({ block: "center" }); }
}, [openId, data, filter]);
const scope = useMemo(() => ({ staffId, ward }), [staffId, ward]);
const counts = useMemo(() => (data ? requestCounts(data, scope) : null), [data, scope]);
const countsRef = useRef(onCounts);
useEffect(() => { countsRef.current = onCounts; }, [onCounts]);
useEffect(() => { if (counts) countsRef.current?.(counts); }, [counts]);
const rows = useMemo(() => (data ? requestsFor(data.requests, filter, scope) : []), [data, filter, scope]);
const pad = title === null ? 0 : "0 16px";
let body: React.ReactNode;
if (!data) {
const loadErr = external ? "" : own.error;
body = loadErr ? (
<div style={{ padding: title === null ? 0 : "0 16px 12px" }}>
<ErrorLine msg={loadErr} />
<div style={{ marginTop: 12 }}><button type="button" className="btn btn-secondary" onClick={() => void own.reload()}>Try again</button></div>
</div>
) : <div style={{ padding: "0 16px" }}><Empty pad={3}>Loading</Empty></div>;
} else if (rows.length === 0) {
// The loading and empty lines always keep side padding: embedded with no title (the staff
// record's Requests tab) the list sits directly inside a bordered panel, and a sentence flush
// against that border reads as broken. Request rows lay themselves out and keep `pad`.
body = <div style={{ padding: "0 16px" }}><Empty pad={3}>{emptyText ?? EMPTY[filter]}</Empty></div>;
} else {
body = rows.map((r) => (
<RequestRowView key={r.id} r={r} open={openRow === r.id} hidePerson={hidePerson}
onToggle={() => setOpenRow((o) => (o === r.id ? null : r.id))} act={act} />
));
}
return (
<div>
<RequestStyles />
<ErrorLine msg={error} />
{title === null
? <div>{body}</div>
: (
<div style={{ marginTop: error ? 12 : 0 }}>
<Panel title={title ?? REQUEST_FILTER_LABEL[filter]} count={data ? rows.length : undefined}>{body}</Panel>
</div>
)}
{data?.moreRequests && (
<p className="tc-meta-line" style={{ margin: "12px 0 0" }}>Only the latest {data.requestLimit} are loaded older ones are on the staff record.</p>
)}
</div>
);
}
/* Who a waiting request can be handed to. The wearer may approve their own (marked as a
* self-approval); the person who raised it never can, and the counter refuses that on the id.
* A manager with no staff-app account cannot be asked, so they are grouped apart. */
type ApproverChoice = { id: string; reachable: boolean; label: string };
function approverChoices(s: Snapshot, r: RequestRow): ApproverChoice[] {
return s.staff
.filter((x) => !x.inactive && x.first && x.id !== r.raisedById && (x.id !== r.staffId || x.managerId === x.id))
.map((x) => ({
id: x.id,
reachable: !!x.selfEmail,
label: `${`${x.first} ${x.last}`.trim()}${x.dept ? ` · ${x.dept}` : ""}`
+ (x.id === r.staffId ? " · this request is theirs — self-approval" : "")
+ (x.selfEmail ? "" : x.selfCode && slipLive(x.selfCodeAt, s.today, s.tz) ? " · code printed, not used yet" : " · no staff-app account"),
}));
}
function RequestRowView({ r, open, hidePerson, onToggle, act }: {
r: RequestRow; open: boolean; hidePerson?: boolean; onToggle: () => void;
act: (op: QueueOp, payload: Record<string, unknown>) => Promise<boolean>;
}) {
const { s } = useSnap();
const [reply, setReply] = useState("");
const [hold, setHold] = useState("");
const [reassign, setReassign] = useState("");
const st = statusText(r, { mine: false, first: r.staffName.split(" ")[0] });
const awaiting = r.status === "awaiting";
const orphan = isStranded(r);
const onRound = r.status === "round" || r.status === "delivered";
const wearerApproves = !!r.managerId && r.managerId === r.staffId;
const approval = awaiting
? (r.managerName ? `with ${r.managerName}${wearerApproves ? " · self-approval" : ""}` : "nobody asked yet")
: r.status === "declined"
? (r.decision || "declined")
: `${r.decision || "Approved"} by ${r.managerName}${wearerApproves ? " · self-approved" : ""}`;
const choices = useMemo(() => (open && awaiting ? approverChoices(s, r) : []), [open, awaiting, s, r]);
const reachable = choices.filter((c) => c.reachable);
const unreachable = choices.filter((c) => !c.reachable);
const picked = open && reassign ? s.staff.find((x) => x.id === reassign) ?? null : null;
const refused = r.lines.filter((l) => l.status === "declined").length;
function toggle() {
setReply(""); setHold(""); setReassign("");
onToggle();
}
async function send() {
if (reply.trim() && await act("request.reply", { id: r.id, body: reply })) setReply("");
}
const orderForm = (
<button type="button" className="btn btn-secondary" aria-label={`Print order form for ${r.code}`}
title={awaiting ? "Everything asked for, managers block blank" : "The approved lines only"}
onClick={() => window.open(`/print/order-form?request=${encodeURIComponent(r.id)}`, "_blank", "noopener")}>
Print order form
</button>
);
return (
<div id={`req-${r.id}`} className={"tc-req-row" + (orphan ? " tc-flag" : "")} style={{ opacity: awaiting && !orphan && !open ? 0.6 : 1 }}>
<div className="tc-req-head">
<span className="tc-mono" style={{ fontSize: 12, fontWeight: 500, width: 74, flex: "none" }}>{r.code}</span>
<span style={{ flex: 1, minWidth: 160 }}>
<b>{r.summary}</b>
{!hidePerson && <span style={{ color: "var(--color-neutral-700)" }}> · {r.staffName}{r.ward ? ` (${r.ward})` : ""}</span>}
</span>
{orphan && <Tag tone="accent">No approver</Tag>}
<Tag tone={NEEDS_STAFF.has(r.status as never) ? "accent" : "quiet"}>{st.label}</Tag>
<button type="button" className="btn btn-ghost" style={{ minHeight: 30, padding: "2px 8px" }} aria-expanded={open} onClick={toggle}
aria-label={open ? `Close ${r.code}` : `Open ${r.code}`}>
{open ? "Close" : `Open${r.lineCount > 1 ? ` · ${r.lineCount} lines` : ""}`}
</button>
</div>
<div className="tc-meta-line tc-req-meta">
{[r.reason, approval, r.raisedByName ? `raised by ${r.raisedByName}` : "", formatInZone(r.createdAt, s.tz)].filter(Boolean).join(" · ")}
</div>
{open && (
<div className="tc-req-body">
<div>
{r.lines.map((l) => {
const off = l.status === "declined";
return (
<div key={l.id} className="tc-req-item">
<span style={{ flex: 1, minWidth: 160, fontWeight: off ? 400 : 600, textDecoration: off ? "line-through" : "none", color: off ? "var(--color-neutral-700)" : undefined }}>
{l.qty} × {l.item}{l.gender && l.gender !== "Unisex" ? ` (${genderLabel(l.gender)})` : ""} <span className="tc-mono">{l.size}</span>
</span>
<Tag tone={l.status === "approved" ? "quiet" : off ? "outline" : "low"}>{l.statusLabel}</Tag>
{off && l.declineReason && <span className="tc-meta-line">{l.declineReason}</span>}
</div>
);
})}
<div className="tc-meta-line" style={{ marginTop: 4 }}>
{r.status === "declined" ? "Nothing to pick"
: awaiting ? `${plural(r.garments, "garment", "garments")} asked for`
: `In the bag: ${plural(r.garments, "garment", "garments")}${refused ? ` · ${plural(refused, "declined line", "declined lines")} not picked` : ""}`}
</div>
</div>
{r.note && <p style={{ fontSize: 13.5, lineHeight: 1.5, margin: "10px 0 0", maxWidth: "70ch" }}>&ldquo;{r.note}&rdquo;</p>}
<div className="tc-req-actions" style={{ marginTop: 12 }}>
{awaiting && (
<>
<select className="input" style={{ width: 280, maxWidth: "100%" }} value={reassign} onChange={(e) => setReassign(e.target.value)}
aria-label={orphan ? `Choose who approves ${r.code}` : `Send ${r.code} to a different approver`}>
<option value="">{orphan ? "Choose an approver…" : "Send it to somebody else…"}</option>
{unreachable.length === 0
? reachable.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)
: (
<>
{reachable.length > 0 && (
<optgroup label="Can decide it today">
{reachable.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
</optgroup>
)}
<optgroup label="Cant be asked — no staff-app account">
{unreachable.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
</optgroup>
</>
)}
</select>
<button type="button" className="btn btn-primary"
onClick={async () => { if (reassign && await act("request.reassign", { id: r.id, managerId: reassign })) setReassign(""); }}>
{orphan ? "Ask them" : "Re-address"}
</button>
<button type="button" className="btn btn-secondary"
onClick={() => { if (confirm(`Withdraw ${r.code}? ${r.staffName} is told it was declined by the linen room.`)) void act("request.withdraw", { id: r.id, reason: "Withdrawn — no approver available" }); }}>
Withdraw it
</button>
{orderForm}
</>
)}
{r.status === "accepted" && <button type="button" className="btn btn-primary" onClick={() => void act("request.pick", { id: r.id })}>Start picking</button>}
{r.status === "picking" && (
<>
<input className="input" style={{ width: 200 }} aria-label={`Held until, for ${r.code}`} placeholder="Held until — e.g. Fri 6pm" value={hold} onChange={(e) => setHold(e.target.value)} />
<button type="button" className="btn btn-primary" onClick={async () => { if (await act("request.hold", { id: r.id, holdUntil: hold })) setHold(""); }}>Hold at the counter</button>
<button type="button" className="btn btn-secondary" onClick={() => void act("request.round", { id: r.id })}>Send on the ward round</button>
</>
)}
{r.status === "ready" && (
<>
<span style={{ fontSize: 13 }}>
Code <span className="tc-mono" style={{ fontSize: 15, fontWeight: 600 }}>{r.collectCode}</span> · {plural(r.garments, "garment", "garments")}{r.holdUntil ? ` · until ${r.holdUntil}` : ""}
</span>
<button type="button" className="btn btn-primary" onClick={() => void act("request.collected", { id: r.id })}>Collected</button>
</>
)}
{r.status === "round" && <span className="tc-meta-line" style={{ fontSize: 13 }}>On the round to {r.ward || "the ward"} · {plural(r.garments, "garment", "garments")}</span>}
{r.status === "delivered" && <span className="tc-meta-line" style={{ fontSize: 13 }}>Signed by {r.signerName}{r.signerRole ? `, ${r.signerRole}` : ""}{r.claimedAt ? " · collected" : " · not yet collected from the ward"}</span>}
{r.status === "collected" && <span className="tc-meta-line" style={{ fontSize: 13 }}>Handed over at the counter</span>}
{r.status === "declined" && <span className="tc-meta-line" style={{ fontSize: 13 }}>Declined {r.declineReason || "no reason recorded"}</span>}
{!awaiting && r.status !== "declined" && (
<>
<button type="button" className="btn btn-secondary" onClick={() => openSlip(onRound ? "delivery" : "collection", requestSlip(s, r))}>
{onRound ? "Delivery slip" : "Collection slip"}
</button>
{orderForm}
</>
)}
</div>
{awaiting && choices.length === 0 && (
<p className="tc-meta-line" style={{ margin: "8px 0 0" }}>Nobody on the register can approve this one <Link href="/app/staff">add a manager</Link> or withdraw it.</p>
)}
{picked && picked.id === r.staffId && (
<p className="tc-meta-line" style={{ margin: "8px 0 0" }}>{picked.first}&apos;s own request sending it to them is a self-approval.</p>
)}
{picked && !picked.selfEmail && (
<p className="tc-meta-line" style={{ margin: "8px 0 0" }}>
{picked.first} can&apos;t be asked:{" "}
{picked.selfCode && slipLive(picked.selfCodeAt, s.today, s.tz)
? <>the code on <Link href={`/app/staff/${picked.id}`}>their record</Link> isn&apos;t used yet.</>
: picked.selfCode
? <>the code on <Link href={`/app/staff/${picked.id}`}>their record</Link> has expired.</>
: <>no staff-app account <Link href={`/app/staff/${picked.id}`}>give them a code</Link>.</>}
</p>
)}
<h3 className="tc-lbl tc-req-sub">Messages</h3>
{r.messages.length === 0 && <div className="tc-meta-line" style={{ padding: "4px 0" }}>No messages.</div>}
{r.messages.map((m) => (
<div key={m.id} className="tc-req-item" style={{ display: "block" }}>
<b>{m.fromStaff ? m.authorName : `${m.authorName} (linen room)`}</b>
<span className="tc-meta-line" style={{ marginLeft: 8 }}>{formatInZone(m.at, s.tz)}</span>
<div style={{ marginTop: 3, lineHeight: 1.5 }}>{m.body}</div>
</div>
))}
<div className="tc-req-actions" style={{ marginTop: 8 }}>
<input className="input" style={{ flex: 1, minWidth: 200 }} aria-label={`Reply about ${r.code}`} placeholder="Reply to this order" value={reply}
onChange={(e) => setReply(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void send(); }} />
<button type="button" className="btn btn-secondary" onClick={() => void send()}>Send</button>
</div>
<h3 className="tc-lbl tc-req-sub">History</h3>
{r.events.map((e) => (
<div key={e.id} className="tc-req-item">
<span style={{ flex: 1, minWidth: 160 }}>{e.label}{e.meta ? ` — ${e.meta}` : ""}</span>
<span className="tc-meta-line">{e.actorName} · {formatInZone(e.at, s.tz)}</span>
</div>
))}
</div>
)}
</div>
);
}