18514c0ee8
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 9fc2edb on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
863 lines
60 KiB
TypeScript
863 lines
60 KiB
TypeScript
"use client";
|
||
/* Staff requests, from the linen room's side.
|
||
*
|
||
* The counter's queue. A request only appears here as something to act on once a ward manager has
|
||
* approved it — anything still `awaiting` is shown, greyed, so the linen room can see what is
|
||
* coming without being able to do anything about it. That asymmetry is the point of the whole
|
||
* flow: approval is the ward's, fulfilment is the linen room's, and neither can do the other's job.
|
||
*
|
||
* A request covers as many garments as the person asked for, one line each, and the manager can
|
||
* knock back individual lines — the tunic and the trousers yes, the fleece no. So every screen
|
||
* here has to keep two ideas apart: `lines` is the record of what was asked, `bag` is what is
|
||
* actually picked. Picking off `lines` would put a garment the ward refused into somebody's hands,
|
||
* so the pick, the count, the slip and the collection code are all built from `bag`.
|
||
*/
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import Link from "next/link";
|
||
import { useSnap } from "@/lib/client";
|
||
import { openSlip } from "@/components/dialogs";
|
||
import { PageHead, Empty, ErrorLine, Field } from "@/components/ui";
|
||
import { csvEsc, csvOf, facilityDate, fmtDate, formatInZone, genderLabel, slipLive, type Snapshot } from "@/lib/compute";
|
||
import { downloadCsv } from "@/lib/print";
|
||
import type { ReqLine } from "@/lib/staffdata";
|
||
import { NEEDS_STAFF, OPEN_REQUEST, WAITLIST_HOLD_HOURS, holdEndsAt, holdExpired, statusText } from "@/lib/staffreq";
|
||
|
||
type Msg = { id: string; fromStaff: boolean; authorName: string; body: string; at: string };
|
||
type Ev = { id: string; label: string; meta: string; actorName: string; at: string };
|
||
type Req = {
|
||
id: string; code: string; status: string; staffId: string; staffName: string; staffNum: string; ward: string;
|
||
/** Everything asked for, declines included — and separately the ones that are actually a pick. */
|
||
lines: ReqLine[]; bag: ReqLine[];
|
||
summary: string; garments: number; lineCount: number; decision: string | null;
|
||
reason: string; note: string;
|
||
managerName: string;
|
||
/** Which person on the register the approver is — not just how their name is spelled. A manager
|
||
* may approve a request raised for herself, and the only thing that can show that happened is
|
||
* this id beside the wearer's: two spellings of one name tell nobody anything. Null while
|
||
* nobody has been asked. */
|
||
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;
|
||
/** Who raised it, and which person on the register that is. The id is what the approver list is
|
||
* built on: a ward register carries people who share a name, and telling them apart by spelling
|
||
* is how the wrong one gets dropped out of a dropdown. Null when the wearer raised it herself,
|
||
* and null when the linen room raised it at the counter — that one is stamped with the
|
||
* coordinator's own account, which is not on the ward register at all. Neither is a name this
|
||
* screen could have offered anyway. */
|
||
raisedById: string | null; raisedByName: string;
|
||
createdAt: string; decidedAt: string | null; messages: Msg[]; events: Ev[];
|
||
};
|
||
/** One name in the re-address dropdown: who they are, whether anything can actually reach them,
|
||
* and the words the coordinator reads before picking them. */
|
||
type ApproverChoice = { id: string; reachable: boolean; label: string };
|
||
type Dispute = { id: string; body: string; staffName: string; staffNum: string; ward: string; at: string };
|
||
type Cycle = { id: string; dueBy: string; openedBy: string; openedAt: string; answers: number };
|
||
type Waiting = { id: string; staffName: string; staffNum: string; ward: string; item: string; size: string; since: string; offeredAt: string | null };
|
||
type Damage = { 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 };
|
||
type Shortfall = {
|
||
id: string; staffId: string; staffName: string; staffNum: string; ward: string;
|
||
item: string; size: string; onRecord: number; confirmed: number; short: number; at: string;
|
||
};
|
||
/* `requestLimit` and `moreRequests` are the endpoint saying how much of the queue this is. It reads
|
||
one row past its own ceiling so that "there are older ones than these" is a fact rather than a
|
||
guess off a full page — and nothing here read it, so the list just stopped at the newest 400 with
|
||
no word to anybody. Every tab on this screen is a view of that same set, so a request from before
|
||
the cut-off is on none of them and in nothing exported from them. */
|
||
type Payload = { requests: Req[]; disputes: Dispute[]; cycle: Cycle | null; shortfalls: Shortfall[]; waiting: Waiting[]; damage: Damage[]; requestLimit: number; moreRequests: boolean };
|
||
|
||
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
||
|
||
/* What a tab is called once it has to be named away from its own button — over the queue it heads,
|
||
and at the top of a file exported off it. One spelling in one place, so a spreadsheet that has
|
||
left the building can never disagree with the screen about which view it came from. */
|
||
const TAB_TITLE = {
|
||
todo: "To do", noapprover: "Needs an approver", open: "Open", all: "All",
|
||
queries: "Record queries", damage: "Damage", cycles: "Kit check & waitlist",
|
||
} as const;
|
||
|
||
/** A request with nothing in its `managerName` never got an approver at all.
|
||
*
|
||
* That happens the moment a manager raises for one of their own reports: they would otherwise be
|
||
* approving their own raise, so the staff app sends it up a level — and when there is nobody above
|
||
* them, or the one above is themselves, it is created with no approver and waits here. Nobody on the ward can move it, so if this
|
||
* screen did not say so out loud it would simply sit in the queue for ever. */
|
||
const stranded = (r: Req) => r.status === "awaiting" && !r.managerName;
|
||
|
||
/** Is this bag going out on the ward round rather than waiting at the counter? Decides which of
|
||
* the two slips is the one worth printing. */
|
||
const onRound = (r: Req) => r.status === "round" || r.status === "delivered";
|
||
|
||
/* Who a waiting request can be handed to, and what has to be said about each name before it is
|
||
* picked.
|
||
*
|
||
* The wearer is on the list like anybody else: anyone may approve for themselves (the owner's
|
||
* decision), and it is marked Self-approved wherever it shows. The option says out loud that this
|
||
* is a self-approval, because otherwise the coordinator is choosing between two spellings of the
|
||
* same person and finds out what they did from the timeline months later.
|
||
*
|
||
* The person who raised it is off the list altogether, and that one has no way back in. A
|
||
* manager asking for one of her own reports' garments is the whole reason the request escalated
|
||
* and landed on this tab with nobody to approve it — and she is the obvious pick, because she IS
|
||
* the wearer's manager on the register, with nothing on the row to say the ask came from her.
|
||
* Handing it back to her would have one person do both halves of a decision the ward is told two
|
||
* people made, so it is turned down the moment the button is pressed. Leaving her in the list
|
||
* made the Needs an approver tab offer the one name on it certain to fail, on the tab that
|
||
* exists to fix exactly that. It is her id that keeps her off the list and nothing else, which is
|
||
* why the queue carries it: a ward can hold two people spelled the same way, only one of them
|
||
* raised this, and the counter refuses on the id too.
|
||
*
|
||
* Reachability is the other half, and nothing refuses it: a manager with no staff-app account
|
||
* cannot be asked at all. The approval e-mail has nowhere to go and they cannot sign in to
|
||
* decide it, so re-addressing to one of them puts the request straight back in the dead end it
|
||
* was being rescued from — except that it does not come back to this tab, because it now has a
|
||
* name against it. Said on the option, before it is chosen. A printed code only counts as a way in
|
||
* while the activation would still take it, which is slipLive's call and nobody else's: "a code is
|
||
* outstanding" is all the register holds, and reading that as live had the dropdown calling a slip
|
||
* worth chasing that the person would be turned away with, while the staff register, looking at
|
||
* the same person, said they had no staff app at all.
|
||
*
|
||
* The raiser rule is the counter's rule said a second time, in a screen, and two copies of a rule
|
||
* agree only until one of them is edited. The queue could settle it by arriving with the answer
|
||
* already worked out — the ids this particular request can be sent to, decided where the refusal
|
||
* itself lives — and then a name is on this list exactly when it would be accepted, and this
|
||
* function is left with nothing to do but the words. */
|
||
function approverChoices(s: Snapshot, r: Req): 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"),
|
||
}));
|
||
}
|
||
|
||
/** The whole ask, line by line, with the manager's answer against each garment.
|
||
*
|
||
* The declines stay on the list rather than being dropped: the wearer will ask why they got two
|
||
* things and not three, and the person at the counter needs the answer in front of them. They are
|
||
* struck through so nobody picks one by mistake. */
|
||
function LineList({ r }: { r: Req }) {
|
||
const refused = r.lines.filter((l) => l.status === "declined").length;
|
||
const note =
|
||
r.status === "declined" ? "Nothing to pick — every line was declined."
|
||
: r.status === "awaiting" ? `${plural(r.garments, "garment", "garments")} asked for. Nothing is picked until the ward has decided.`
|
||
: refused > 0 ? `In the bag: ${plural(r.garments, "garment", "garments")} across ${plural(r.bag.length, "line", "lines")}. The ${refused === 1 ? "declined line is" : `${refused} declined lines are`} not picked.`
|
||
: `In the bag: ${plural(r.garments, "garment", "garments")}.`;
|
||
return (
|
||
<div style={{ marginBottom: "var(--space-3)" }}>
|
||
{r.lines.map((l) => {
|
||
const off = l.status === "declined";
|
||
return (
|
||
<div key={l.id} style={{ display: "flex", gap: "var(--space-3)", alignItems: "baseline", padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13.5, flexWrap: "wrap" }}>
|
||
<span style={{ flex: 1, minWidth: 180, 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)})` : ""} — {l.size}
|
||
</span>
|
||
<span className={l.status === "approved" ? "tag tag-neutral" : off ? "tag tag-outline" : "tag tag-accent"}>{l.statusLabel}</span>
|
||
{off && l.declineReason && <span style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>{l.declineReason}</span>}
|
||
</div>
|
||
);
|
||
})}
|
||
<div style={{ fontSize: 12.5, color: "var(--color-neutral-700)", marginTop: "var(--space-2)" }}>{note}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function RequestsPage() {
|
||
const { s, mutate } = useSnap();
|
||
const [data, setData] = useState<Payload | null>(null);
|
||
const [tab, setTab] = useState<"todo" | "noapprover" | "open" | "all" | "queries" | "damage" | "cycles">("todo");
|
||
const [dueBy, setDueBy] = useState("");
|
||
const [openId, setOpenId] = useState<string | null>(null);
|
||
const [reply, setReply] = useState("");
|
||
const [hold, setHold] = useState("");
|
||
/** The replacement approver picked for a stranded `awaiting` request. */
|
||
const [reassign, setReassign] = useState("");
|
||
const [err, setErr] = useState("");
|
||
|
||
/* "Still loading" and "the queue never arrived" look identical from the outside, and this screen
|
||
is the linen room's work list — reading it as empty when the fetch failed means a ward waits on
|
||
a request nobody knows about. So the failure is said out loud and can be retried. */
|
||
const [loadErr, setLoadErr] = useState("");
|
||
const load = useCallback(async () => {
|
||
setLoadErr("");
|
||
try {
|
||
const r = await fetch("/api/requests");
|
||
if (!r.ok) { const j = await r.json().catch(() => ({})); setLoadErr(j.error || "Couldn’t load the request queue."); return; }
|
||
setData(await r.json());
|
||
} catch {
|
||
setLoadErr("Couldn’t reach the server — the request queue isn’t loaded.");
|
||
}
|
||
}, []);
|
||
useEffect(() => { void load(); }, [load]);
|
||
|
||
async function act(op: string, payload: unknown) {
|
||
setErr("");
|
||
const r = await mutate(op, payload);
|
||
if (!r.ok) { setErr(r.error); return false; }
|
||
await load();
|
||
return true;
|
||
}
|
||
|
||
/* What goes on the printed slip.
|
||
*
|
||
* The bag, never the whole ask: a slip that listed a garment the ward declined would have
|
||
* somebody hunting the shelf for it, and the person signing would sign for three things and get
|
||
* two. The request's own code goes in the order-number field and the collection code is printed
|
||
* beside the name, because one code now covers several garments and it is the only thing that
|
||
* ties this piece of paper to that bag. */
|
||
const slipFor = (r: Req) => ({
|
||
staffName: r.staffName, dept: r.ward, deliverTo: r.ward,
|
||
sets: r.garments, po: r.code, code: r.collectCode || "",
|
||
// The cut goes on the slip. Two garments can share a name and differ only by it — an
|
||
// Ambassador Shirt comes men's and ladies, on different style codes and different shelves —
|
||
// and a line reading "1 × Ambassador Shirt — M" gives whoever is picking no way to tell which,
|
||
// which is a wrong garment in the bag and a return later. Omitted for unisex, where it is noise.
|
||
lines: r.bag.map((l) => `${l.qty} × ${l.item}${l.gender && l.gender !== "Unisex" ? ` (${genderLabel(l.gender)})` : ""} — ${l.size}`).join("\n"),
|
||
dateReceived: s.today, requestedBy: r.staffNum,
|
||
deliveredBy: s.settings.coordinator, dateTime: s.today,
|
||
});
|
||
|
||
/* The dropdown's list, worked out once and only for the row that is actually open.
|
||
*
|
||
* Every name on it is a walk of the whole register, and it used to be built for every waiting
|
||
* request on screen although only the open one can show a dropdown — on a busy register, forty
|
||
* walks to draw one list. The reply box further down shares this component's state, so that
|
||
* whole pass ran again on every letter typed into a message to a ward. */
|
||
const openReq = data?.requests.find((r) => r.id === openId) ?? null;
|
||
const choices = useMemo<ApproverChoice[]>(
|
||
() => (openReq && openReq.status === "awaiting" ? approverChoices(s, openReq) : []),
|
||
[s, openReq],
|
||
);
|
||
|
||
if (!data) return (
|
||
<section>
|
||
<PageHead eyebrow="Ward requests" title="Staff requests" />
|
||
{loadErr ? (
|
||
<>
|
||
<ErrorLine msg={loadErr} />
|
||
<div style={{ marginTop: "var(--space-3)" }}><button className="btn btn-secondary" onClick={() => void load()}>Try again</button></div>
|
||
</>
|
||
) : <Empty>Loading…</Empty>}
|
||
</section>
|
||
);
|
||
|
||
// "To do" is the linen room's actual work queue: approved and not yet handed over.
|
||
const todo = data.requests.filter((r) => ["accepted", "picking", "ready", "round"].includes(r.status));
|
||
const open = data.requests.filter((r) => OPEN_REQUEST.has(r.status as never));
|
||
// Requests nobody was ever asked to approve. Their own tab because they are the only thing on
|
||
// this screen that is stuck rather than merely waiting, and the fix — give it an approver — is
|
||
// the linen room's to make and nobody else's.
|
||
const noApprover = data.requests.filter(stranded);
|
||
const rows = tab === "todo" ? todo : tab === "noapprover" ? noApprover : tab === "open" ? open : tab === "all" ? data.requests : [];
|
||
/* The queue arrives newest first and stops at its ceiling, so what is missing is always the
|
||
oldest — and old is exactly what a stranded request or a bag nobody collected becomes. Every
|
||
request tab is a narrowing of that one set, so every one of them carries the mark, not just
|
||
All: a coordinator who cleared Needs an approver to a bare 0 would take the ward's stuck
|
||
requests to be dealt with while the longest-stuck of them sat past the cut-off, unseen. */
|
||
const more = data.moreRequests ? "+" : "";
|
||
const inLoaded = data.moreRequests ? ` among the most recent ${data.requestLimit} requests` : "";
|
||
|
||
/* Export — the tab on screen, and nothing else.
|
||
|
||
Every tab here is a view of the same queue narrowed a different way, so a coordinator who has
|
||
narrowed to To do and hits Export means that work queue, not eighteen months of requests. There
|
||
is no search box on this screen, so the tab is the only filter in force and honouring it is the
|
||
whole job. The file is named after the tab as well: four exports all called
|
||
threadcount-requests-2026-09-11.csv land in one Downloads folder as "(1)" and "(2)", and by
|
||
Monday nobody can say which one the ward was sent.
|
||
|
||
The last three tabs are not narrowings of the request queue at all — a record query, a damage
|
||
report and a kit-check answer share no columns with a request and no columns with each other —
|
||
so each writes its own table rather than being forced into one shape with most cells empty.
|
||
Kit check & waitlist is two registers on one screen, so it writes two tables into the one file,
|
||
the way the pre-loved report does; folding them together would put a garment somebody is
|
||
queueing for in the same column as a garment somebody has lost. */
|
||
const shown = tab === "queries" ? data.disputes.length
|
||
: tab === "damage" ? data.damage.length
|
||
: tab === "cycles" ? data.shortfalls.length + data.waiting.length
|
||
: rows.length;
|
||
|
||
function exportCsv() {
|
||
// Hoisted, so the checker cannot see the early return above that already proved this is here —
|
||
// and it is right not to: a function declaration can be called from anywhere in the body. The
|
||
// button is only rendered once the data has loaded, so this never fires; it is here to make the
|
||
// guarantee local to the function that relies on it.
|
||
if (!data) return;
|
||
/* Full date and 24-hour time, in the facility's zone, with the zone named at the top of the
|
||
file. The screen prints "9 Sep" because you read it in order; a spreadsheet gets re-sorted the
|
||
moment it lands, and "9 Sep, 14:32" sorts as text into nonsense and carries no year at all.
|
||
Empty rather than an em dash where there is no instant — a dash in a spreadsheet cell is only
|
||
noise to filter around. */
|
||
const when = (iso: string | null) =>
|
||
iso ? `${facilityDate(iso, s.tz)} ${formatInZone(iso, s.tz, { hour: "2-digit", minute: "2-digit", hour12: false, hourCycle: "h23" })}` : "";
|
||
/* The header block above the column headings, so a file that has left the building still says
|
||
which view it is, when it was taken and what zone its times are in. */
|
||
const preamble = (facts: [string, string | number][]) =>
|
||
facts.map(([k, v]) => `${csvEsc(k)},${typeof v === "number" ? v : csvEsc(v)}`).join("\n") + "\n\n";
|
||
|
||
if (tab === "queries") {
|
||
downloadCsv(`threadcount-record-queries-${s.today}.csv`,
|
||
preamble([["Record queries", "Raised against a staff record, not yet sorted"], ["Exported", s.today], ["Times shown in", s.tz], ["Queries in this file", data.disputes.length]])
|
||
+ csvOf(["Raised", "Staff no.", "Staff member", "Ward", "What they say is wrong"],
|
||
data.disputes.map((d) => [when(d.at), d.staffNum, d.staffName, d.ward, d.body])));
|
||
return;
|
||
}
|
||
|
||
if (tab === "damage") {
|
||
downloadCsv(`threadcount-damage-${s.today}.csv`,
|
||
preamble([["Damage reported", "Not yet handed in at the counter"], ["Exported", s.today], ["Times shown in", s.tz], ["Reports in this file", data.damage.length]])
|
||
+ csvOf(["Reported", "Staff no.", "Staff member", "Ward", "Garment", "Size", "Damage", "What they said", "Replacement requested", "Photo"],
|
||
// The issue a report was raised against can be deleted, and the screen says so in words
|
||
// rather than showing a blank. A blank cell here would read as a gap in the export.
|
||
data.damage.map((d) => [when(d.at), d.staffNum, d.staffName, d.ward, d.item || "Garment no longer on file", d.size, d.kind, d.note, d.requestCode, d.photoId ? "Yes" : "No"])));
|
||
return;
|
||
}
|
||
|
||
if (tab === "cycles") {
|
||
const c = data.cycle;
|
||
downloadCsv(`threadcount-kit-check-${s.today}.csv`,
|
||
preamble([
|
||
["Kit check and waitlist", c ? `Running — due by ${c.dueBy}` : "No kit check running"],
|
||
["Opened by", c ? c.openedBy || "—" : ""],
|
||
["Answers in", c ? c.answers : 0],
|
||
["Exported", s.today],
|
||
["Times shown in", s.tz],
|
||
])
|
||
// Nothing in this table has changed anybody's record, exactly as the screen says. It is the
|
||
// working list for squaring the register one garment at a time, so it goes out with the
|
||
// person and the size on every row rather than as a count of answers.
|
||
+ "What people couldn't account for\n"
|
||
+ csvOf(["Staff no.", "Staff member", "Ward", "Garment", "Size", "On record", "Confirmed", "Short", "Answered"],
|
||
data.shortfalls.map((f) => [f.staffNum, f.staffName, f.ward, f.item, f.size, f.onRecord, f.confirmed, f.short, when(f.at)]))
|
||
+ "\nWaiting for a size\n"
|
||
+ csvOf(["Staff no.", "Staff member", "Ward", "Garment", "Size", "Waiting since", "Offered", "Held until", "Hold"],
|
||
data.waiting.map((w) => {
|
||
// The deadline is computed the one way the product computes it, so a file taken off
|
||
// this screen can never disagree with the screen about whose garment it still is.
|
||
const ends = holdEndsAt(w.offeredAt);
|
||
return [w.staffNum, w.staffName, w.ward, w.item, w.size, when(w.since), when(w.offeredAt),
|
||
ends ? when(ends.toISOString()) : "",
|
||
!w.offeredAt ? "Not offered yet" : holdExpired(w.offeredAt) ? "Lapsed — offer to the next person" : "Held"];
|
||
})));
|
||
return;
|
||
}
|
||
|
||
/* A row is a garment, not a request.
|
||
|
||
A request covers as many garments as the person asked for and the manager decides each one
|
||
separately — the tunic and the trousers yes, the fleece no. One row per request could only
|
||
carry the rollup, "2 of 3 approved", and the question this file is opened to answer is
|
||
precisely the one that would then be missing: which garment was refused, and why. So the
|
||
request's own facts repeat down its lines. That repetition is what makes the file worth
|
||
having in a spreadsheet — every declined fleece in the hospital is one filter on Line
|
||
decision — and the count in the preamble says how many requests those rows came from, so
|
||
nobody reads nineteen rows as nineteen requests.
|
||
|
||
The two decisions keep their own words, because they are not the same decision and this file
|
||
goes to a ward manager. A LINE is approved or declined, and Line decision is the word the
|
||
line already carries from lineStatusLabel(). A REQUEST is accepted or declined, and Request
|
||
status is statusText()'s label — the same words as the tag on the row. Decision summary is
|
||
decisionSummary()'s rollup and nothing recomputed here. Request decline reason is the
|
||
request-level one, which is as often the linen room withdrawing an unapprovable request as
|
||
it is the ward refusing the whole ask.
|
||
|
||
A blank Approver is the Needs an approver tab's own definition — nobody was ever asked — so
|
||
those requests stay identifiable after they have been filed away with the rest. Approver is
|
||
the wearer says a manager was asked to sign for her own kit, which the product allows and
|
||
the ward may reasonably want to see; two matching names in adjacent columns is not something
|
||
anybody spots reading down a file, and on a ward where two people share a name it is not
|
||
even true. It is a fact about who was asked, so it is filled in on a request still waiting
|
||
as much as on one already decided.
|
||
|
||
Held until is left out: it is free text somebody typed at the counter ("Fri 6pm"), and a
|
||
column of that sorts into nonsense beside four real dates. Ward is the wearer's ward, as the
|
||
row on screen states it; a bag already out on a round was routed to the ward she was on when
|
||
the trolley loaded, which after a transfer is a different one. */
|
||
const title = TAB_TITLE[tab];
|
||
downloadCsv(`threadcount-requests-${tab === "noapprover" ? "needs-an-approver" : tab}-${s.today}.csv`,
|
||
preamble([
|
||
["Ward requests", title],
|
||
["Exported", s.today],
|
||
["Times shown in", s.tz],
|
||
["Requests in this file", rows.length],
|
||
// A file that is short of the register says so in its own header, because the person who
|
||
// opens it in three months has no screen beside it to work that out from.
|
||
...(data.moreRequests
|
||
? ([["Older requests not in this file", `The screen holds the most recent ${data.requestLimit} requests and there are older ones than those`]] as [string, string][])
|
||
: []),
|
||
["Rows", "One per line on the request — a request for a tunic and two pairs of trousers is two rows, and the pairs are a Qty of 2 on the second"],
|
||
])
|
||
+ csvOf(["Request", "Raised", "Staff no.", "Staff member", "Ward", "Raised by", "Reason", "Note", "Request status", "Approver", "Approver is the wearer", "Decision summary", "Decided", "Request decline reason", "Collection code", "Garment", "Cut", "Size", "Qty", "Line decision", "Line decline reason"],
|
||
rows.flatMap((r) => {
|
||
const req: (string | number)[] = [
|
||
r.code, when(r.createdAt), r.staffNum, r.staffName, r.ward, r.raisedByName, r.reason, r.note,
|
||
statusText(r).label, r.managerName, r.managerId && r.managerId === r.staffId ? "Yes" : "",
|
||
r.decision ?? "", when(r.decidedAt), r.declineReason ?? "", r.collectCode ?? "",
|
||
];
|
||
/* A request with no lines on it still has to appear. It is only ever a half-written raise
|
||
or one whose garment was deleted from the catalogue, but it is sitting in somebody's
|
||
queue, and a file built by walking lines would drop it silently — which on the Needs an
|
||
approver tab would hide the one kind of request nobody else can rescue. */
|
||
const lines: (ReqLine | null)[] = r.lines.length ? r.lines : [null];
|
||
return lines.map((l) => [...req,
|
||
l ? l.item : "", l ? genderLabel(l.gender) : "", l ? l.size : "", l ? l.qty : "",
|
||
l ? l.statusLabel : "", l ? l.declineReason ?? "" : ""]);
|
||
})));
|
||
}
|
||
|
||
/* The two optional names are for the actions that repeat down the queue. A button reading "Print
|
||
order form" says nothing about which request it belongs to once you are hearing it rather than
|
||
looking at it, and the counter shares a printer — so what a button is about to put on paper is
|
||
worth knowing before it is pressed. */
|
||
const Btn = ({ label, onClick, primary, ariaLabel, title }: { label: string; onClick: () => void; primary?: boolean; ariaLabel?: string; title?: string }) => (
|
||
<button className={primary ? "btn btn-primary" : "btn btn-secondary"} style={{ minHeight: 34 }} onClick={onClick} aria-label={ariaLabel} title={title}>{label}</button>
|
||
);
|
||
|
||
return (
|
||
<section>
|
||
<PageHead
|
||
eyebrow="Ward requests"
|
||
title="Staff requests"
|
||
sub="Raised in the staff app, approved by the ward manager, fulfilled here."
|
||
>
|
||
{/* One button, and it writes the tab you are looking at. The count is said out loud wherever
|
||
the tab is a narrowing, because that is the difference between a file of this morning's
|
||
work and a file of the whole register, and the two are indistinguishable once they are
|
||
attachments on an email. Not on Kit check & waitlist: that tab counts one of its two
|
||
lists, and a number here that disagreed with the number on the tab would be read as a
|
||
bug in the file rather than as two different things being counted. */}
|
||
<button className="btn btn-ghost" onClick={exportCsv} disabled={shown === 0}
|
||
title={tab === "cycles" ? "Downloads the kit check and the waitlist."
|
||
: tab === "queries" ? "Downloads the record queries on screen."
|
||
: tab === "damage" ? "Downloads the damage reports on screen."
|
||
: "Downloads the tab on screen, one row per garment."}>
|
||
{tab === "all" || tab === "cycles" ? "Export CSV" : `Export CSV (${shown} shown)`}
|
||
</button>
|
||
</PageHead>
|
||
<ErrorLine msg={err} />
|
||
<ErrorLine msg={loadErr} />
|
||
|
||
{/* A stranded request is the one thing here that nobody else can rescue. It never reaches the
|
||
To do queue, it looks like any other greyed `awaiting` row in Open, and the ward is sitting
|
||
waiting on an approval that was never asked for — so it is said before the tabs rather
|
||
than found by opening one. */}
|
||
{noApprover.length > 0 && tab !== "noapprover" && (
|
||
/* A rule down the edge, a mark and a heavier figure rather than a red box. The primary
|
||
button an inch away is the same red, so a red outline on its own is not a signal — and
|
||
this is the one thing on the screen nobody but the linen room can rescue. */
|
||
<div className="tc-flag" style={{ borderTop: "2px solid var(--color-text)", borderRight: "2px solid var(--color-text)", borderBottom: "2px solid var(--color-text)", padding: "var(--space-3)", marginTop: "var(--space-4)", display: "flex", gap: "var(--space-3)", alignItems: "baseline", flexWrap: "wrap" }}>
|
||
<b style={{ flex: 1, minWidth: 260, fontSize: 13.5, lineHeight: 1.6 }}>
|
||
<span className="tc-mark" aria-hidden="true" />
|
||
<span className="tc-row-fig" style={{ color: "var(--color-accent-700)", marginRight: 6 }}>{noApprover.length}</span>
|
||
{noApprover.length === 1 ? "request has" : "requests have"} nobody to approve {noApprover.length === 1 ? "it" : "them"} —
|
||
the ward is waiting on a decision that was never asked for.
|
||
</b>
|
||
<button className="btn btn-primary" style={{ minHeight: 34 }} onClick={() => setTab("noapprover")}>Address {noApprover.length === 1 ? "it" : "them"}</button>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-4)", flexWrap: "wrap" }}>
|
||
{([["todo", `To do ${todo.length}${more}`], ["noapprover", `Needs an approver ${noApprover.length}${more}`], ["open", `Open ${open.length}${more}`], ["all", `All ${data.requests.length}${more}`], ["queries", `Record queries ${data.disputes.length}`], ["damage", `Damage ${data.damage.length}`], ["cycles", `Kit check & waitlist ${data.waiting.length}`]] as const).map(([k, lbl]) => (
|
||
<button key={k} className={tab === k ? "btn btn-primary" : "btn btn-secondary"} style={{ minHeight: 34 }} onClick={() => setTab(k)}>{lbl}</button>
|
||
))}
|
||
</div>
|
||
|
||
{tab === "cycles" ? (
|
||
<>
|
||
<div className="sec" style={{ marginTop: "var(--space-5)" }}>Kit check</div>
|
||
{data.cycle ? (
|
||
<div style={{ padding: "var(--space-3) 0", borderBottom: "1px solid var(--color-divider)" }}>
|
||
<div style={{ display: "flex", gap: "var(--space-3)", alignItems: "baseline", flexWrap: "wrap" }}>
|
||
<b style={{ flex: 1 }}>Running — due by {fmtDate(data.cycle.dueBy)}</b>
|
||
<span style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>
|
||
{data.cycle.answers} answer{data.cycle.answers === 1 ? "" : "s"} in · opened by {data.cycle.openedBy || "—"}
|
||
</span>
|
||
<Btn label="Close the round" onClick={() => act("kitcheck.close", { id: data.cycle!.id })} />
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div style={{ padding: "var(--space-3) 0", borderBottom: "1px solid var(--color-divider)", display: "flex", gap: "var(--space-2)", alignItems: "flex-end", flexWrap: "wrap" }}>
|
||
<Field label="Due by" style={{ width: 200 }}>{(c) => <input {...c} className="input" type="date" value={dueBy} onChange={(e) => setDueBy(e.target.value)} />}</Field>
|
||
<Btn primary label="Start a kit check" onClick={async () => { if (await act("kitcheck.open", { dueBy })) setDueBy(""); }} />
|
||
<span style={{ fontSize: 13, color: "var(--color-neutral-700)", flex: 1, minWidth: 240 }}>
|
||
Asks everyone holding uniform to confirm what they have.
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* The answers themselves, which nothing in the product used to show.
|
||
People answer a kit check garment by garment, and the shortfalls are the only reason
|
||
to run one — a count of replies tells the linen room nothing it can act on. Written
|
||
down here, per person and per size, they are the working list for correcting the
|
||
register: open the record, hand in or write off the line, and the next cycle starts
|
||
from a register that is true. */}
|
||
<div className="sec" style={{ marginTop: "var(--space-6)" }}>What people couldn't account for</div>
|
||
{!data.cycle ? (
|
||
<Empty pad={3}>No kit check is running.</Empty>
|
||
) : data.shortfalls.length === 0 ? (
|
||
<Empty pad={3}>
|
||
{data.cycle.answers === 0
|
||
? "Nobody has answered yet."
|
||
: `Every one of the ${data.cycle.answers} answer${data.cycle.answers === 1 ? "" : "s"} so far matched the record.`}
|
||
</Empty>
|
||
) : (
|
||
<>
|
||
<div className="table-wrap"><table className="table" style={{ marginTop: "var(--space-2)" }}>
|
||
<thead><tr>
|
||
<th style={{ textAlign: "left" }}>Who</th>
|
||
<th style={{ textAlign: "left" }}>Garment</th>
|
||
<th style={{ textAlign: "left" }}>Size</th>
|
||
<th style={{ textAlign: "right" }}>On record</th>
|
||
<th style={{ textAlign: "right" }}>Confirmed</th>
|
||
<th style={{ textAlign: "right" }}>Short</th>
|
||
<th style={{ textAlign: "left" }}>Answered</th>
|
||
<th />
|
||
</tr></thead>
|
||
<tbody>
|
||
{data.shortfalls.map((f) => (
|
||
<tr key={f.id}>
|
||
<td>{f.staffName}<span style={{ color: "var(--color-neutral-700)" }}> · {f.staffNum}{f.ward ? ` · ${f.ward}` : ""}</span></td>
|
||
<td>{f.item}</td>
|
||
<td>{f.size}</td>
|
||
<td style={{ textAlign: "right", fontVariantNumeric: "tabular-nums" }}>{f.onRecord}</td>
|
||
<td style={{ textAlign: "right", fontVariantNumeric: "tabular-nums" }}>{f.confirmed}</td>
|
||
{/* The number this table exists for. Marked as well as coloured: every other
|
||
figure in the row is a plain count, and what tells them apart across the
|
||
counter is the mark, not another shade of the brand red. */}
|
||
<td style={{ textAlign: "right", fontVariantNumeric: "tabular-nums", fontWeight: 800, color: "var(--color-accent-700)", whiteSpace: "nowrap" }}><span className="tc-mark" aria-hidden="true" />{f.short}</td>
|
||
<td style={{ whiteSpace: "nowrap", color: "var(--color-neutral-700)" }}>{formatInZone(f.at, s.tz)}</td>
|
||
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}><Link href={`/app/staff/${f.staffId}`}>Open their record</Link></td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table></div>
|
||
<p style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-neutral-700)", marginTop: "var(--space-3)", maxWidth: "70ch" }}>
|
||
Nothing here changes a record — open it and return the missing garments as “Written off”.
|
||
</p>
|
||
</>
|
||
)}
|
||
|
||
<div className="sec" style={{ marginTop: "var(--space-6)" }}>Waiting for a size</div>
|
||
{data.waiting.length === 0 && <Empty pad={3}>Nobody is waiting on a size.</Empty>}
|
||
{data.waiting.map((w) => {
|
||
// The hold is a real deadline, not wording: lib/staffops refuses an accept once it has
|
||
// run out, so the counter has to be able to see that the garment is theirs to give to
|
||
// the next person rather than still being held for somebody who never came back.
|
||
const ends = holdEndsAt(w.offeredAt);
|
||
return (
|
||
<div key={w.id} style={{ display: "flex", gap: "var(--space-3)", alignItems: "baseline", padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13.5, flexWrap: "wrap" }}>
|
||
<b style={{ flex: 1, minWidth: 200 }}>{w.item} — {w.size}<span style={{ fontWeight: 400, color: "var(--color-neutral-700)" }}> · {w.staffName}{w.ward ? ` (${w.ward})` : ""}</span></b>
|
||
<span style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>since {formatInZone(w.since, s.tz)}</span>
|
||
{!w.offeredAt
|
||
? <Btn label="It’s in — offer it" onClick={() => act("waitlist.offer", { id: w.id })} />
|
||
: holdExpired(w.offeredAt)
|
||
? <span className="tag tag-neutral">Hold lapsed — offer to the next person</span>
|
||
: <span className="tag tag-accent">Held until {ends ? formatInZone(ends, s.tz, { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" }) : "—"}</span>}
|
||
</div>
|
||
);
|
||
})}
|
||
<p style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-neutral-700)", marginTop: "var(--space-4)", maxWidth: "70ch" }}>
|
||
Offering tells them and holds the garment for {WAITLIST_HOLD_HOURS} hours.
|
||
</p>
|
||
</>
|
||
) : tab === "damage" ? (
|
||
data.damage.length === 0 ? (
|
||
<Empty pad={4}>Nothing reported damaged that hasn't come back yet.</Empty>
|
||
) : (
|
||
<>
|
||
<div className="tc-panel tc-panel-list" style={{ marginTop: "var(--space-4)" }}>
|
||
<div className="tc-panel-head"><span>{TAB_TITLE.damage}</span><span className="tc-panel-aside">{plural(data.damage.length, "report", "reports")} still to come back</span></div>
|
||
{data.damage.map((d, i) => (
|
||
<div key={d.id} style={{ borderBottom: i === data.damage.length - 1 ? "none" : "1px solid var(--color-divider)", padding: "var(--space-3) var(--space-4)" }}>
|
||
<div style={{ display: "flex", gap: "var(--space-3)", alignItems: "baseline", flexWrap: "wrap" }}>
|
||
<b style={{ flex: 1, minWidth: 200 }}>
|
||
{d.item ? `${d.item}${d.size ? ` — ${d.size}` : ""}` : "Garment no longer on file"}
|
||
<span style={{ fontWeight: 400, color: "var(--color-neutral-700)" }}> · {d.staffName} ({d.staffNum}{d.ward ? ` · ${d.ward}` : ""})</span>
|
||
</b>
|
||
<span className="tag tag-accent">{d.kind}</span>
|
||
<span style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>{formatInZone(d.at, s.tz)}</span>
|
||
<Btn label="Handed in at the counter" onClick={() => act("damage.handedIn", { id: d.id })} />
|
||
</div>
|
||
<div style={{ fontSize: 12.5, color: "var(--color-neutral-700)", marginTop: 4 }}>
|
||
{d.requestCode ? `Replacement requested — ${d.requestCode}` : "No replacement asked for"}
|
||
{d.photoId ? " · photo attached" : ""}
|
||
{" · "}<Link href={`/app/staff/${d.staffId}`}>Open their record</Link>
|
||
</div>
|
||
{d.note && <p style={{ fontSize: 14, lineHeight: 1.6, margin: "var(--space-2) 0 0", maxWidth: "70ch" }}>“{d.note}”</p>}
|
||
{d.photoId && (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img src={`/api/photo/${d.photoId}`} alt="The damage as reported" style={{ maxWidth: 220, marginTop: "var(--space-2)", border: "2px solid var(--color-divider)" }} />
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<p style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-neutral-700)", marginTop: "var(--space-4)", maxWidth: "70ch" }}>
|
||
Handed in only clears the report — return the garment on their staff record.
|
||
</p>
|
||
</>
|
||
)
|
||
) : tab === "queries" ? (
|
||
data.disputes.length === 0 ? (
|
||
<Empty pad={4}>Nobody has queried their record.</Empty>
|
||
) : (
|
||
<div className="tc-panel tc-panel-list" style={{ marginTop: "var(--space-4)" }}>
|
||
<div className="tc-panel-head"><span>{TAB_TITLE.queries}</span><span className="tc-panel-aside">{plural(data.disputes.length, "record", "records")} somebody says is wrong</span></div>
|
||
{data.disputes.map((d, i) => (
|
||
<div key={d.id} style={{ borderBottom: i === data.disputes.length - 1 ? "none" : "1px solid var(--color-divider)", padding: "var(--space-3) var(--space-4)" }}>
|
||
<div style={{ display: "flex", gap: "var(--space-3)", alignItems: "baseline", flexWrap: "wrap" }}>
|
||
<b style={{ flex: 1 }}>{d.staffName} <span style={{ fontWeight: 400, color: "var(--color-neutral-700)" }}>({d.staffNum}{d.ward ? ` · ${d.ward}` : ""})</span></b>
|
||
<span style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>{formatInZone(d.at, s.tz)}</span>
|
||
<Btn label="Mark sorted" onClick={() => act("dispute.resolve", { id: d.id })} />
|
||
</div>
|
||
<p style={{ fontSize: 14, lineHeight: 1.6, margin: "var(--space-2) 0 0", maxWidth: "70ch" }}>{d.body}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
) : (
|
||
<>
|
||
{rows.length === 0 ? (
|
||
<Empty pad={4}>
|
||
{tab === "todo" ? `Nothing approved and waiting${inLoaded}.`
|
||
: tab === "noapprover" ? `Every request waiting${inLoaded} has somebody to approve it.`
|
||
: data.moreRequests ? `Nothing here${inLoaded}.` : "Nothing here yet."}
|
||
</Empty>
|
||
) : (
|
||
<div className="tc-panel tc-panel-list" style={{ marginTop: "var(--space-4)" }}>
|
||
<div className="tc-panel-head">
|
||
<span>{TAB_TITLE[tab]}</span>
|
||
<span className="tc-panel-aside">{plural(rows.length, "request", "requests")}</span>
|
||
</div>
|
||
{rows.map((r, i) => {
|
||
const st = statusText(r);
|
||
const isOpen = openId === r.id;
|
||
const awaiting = r.status === "awaiting";
|
||
const orphan = stranded(r);
|
||
/* The dropdown belongs to the open row alone, and so does `choices`, which is drawn up
|
||
above for that row. Two requests waiting at the same moment can have different answers
|
||
— each leaves out whoever raised it — so it is the open row's question that gets asked.
|
||
`picked` is whoever is chosen in that dropdown; opening any row clears the choice. */
|
||
const reachable = isOpen ? choices.filter((c) => c.reachable) : [];
|
||
const unreachable = isOpen ? choices.filter((c) => !c.reachable) : [];
|
||
const picked = isOpen && reassign ? s.staff.find((x) => x.id === reassign) ?? null : null;
|
||
/* Who answered, and what they answered. A request that got as far as the linen room was
|
||
approved, so the decision line carries the manager's name; a decline is left standing
|
||
on its own, because a withdrawal at the counter also lands here and attributing that
|
||
to the manager who was asked would be a lie on the face of the queue.
|
||
The approver being the person the request is for is allowed — a manager signs for her
|
||
own uniform the same as anybody's — and her record says so wherever it shows. This
|
||
queue said only a name, and a name that happens to match the one three words to its
|
||
left is not something anybody notices reading down a queue. Matched on the id, because
|
||
a ward can carry two people spelled the same way. */
|
||
const wearerApproves = !!r.managerId && r.managerId === r.staffId;
|
||
const approval = awaiting
|
||
? (r.managerName ? `with ${r.managerName}${wearerApproves ? " — their own request, theirs to approve" : ""}` : "nobody has been asked yet")
|
||
: r.status === "declined"
|
||
? (r.decision || "declined")
|
||
: `${r.decision || "Approved"} by ${r.managerName}${wearerApproves ? " — their own request, self-approved" : ""}`;
|
||
/* The ward's order form, with this request's garments already on it.
|
||
*
|
||
* Not the same piece of paper as the collection slip. The slip travels with the bag and is
|
||
* what somebody signs at the handover; the order form is the record of the ask — the sheet
|
||
* the ward used to fill in by hand and send down, and the one a signature goes on. Printing
|
||
* it from the request is the only way the paper and the app can agree about the sizes,
|
||
* because the alternative is somebody copying them out again. The office-use block comes
|
||
* out blank: it is filled in at the counter and the app does not know any of it yet.
|
||
*
|
||
* It prints on an undecided request on purpose, and it is safe to: the form leaves off
|
||
* every declined line, so once the ward has answered it is the bag, and while the ward is
|
||
* still deciding there is nothing to leave off and it is the whole ask. What keeps the two
|
||
* apart on paper is the manager's block — it prints blank, with an unsigned rule where the
|
||
* delegate approves the sets, so an undecided request comes off the printer plainly
|
||
* unapproved. That is the one state the form is actually for: the sheet is what the
|
||
* request is short of, and it can be walked up to the ward and signed there.
|
||
*
|
||
* The code goes on the end of the spoken name, not in place of the visible words: somebody
|
||
* driving the counter by voice — hands full of garments, which is most of the shift — says
|
||
* what is written on the button, and a name that did not start with those words leaves them
|
||
* pressing nothing and wondering why. */
|
||
const orderForm = (
|
||
<Btn label="Print order form"
|
||
ariaLabel={`Print order form for ${r.code}`}
|
||
title={awaiting
|
||
? "Everything asked for, with the manager's block blank to sign."
|
||
: "The approved lines only."}
|
||
onClick={() => window.open(`/print/order-form?request=${encodeURIComponent(r.id)}`, "_blank", "noopener")} />
|
||
);
|
||
return (
|
||
// A stranded request is not dimmed with the rest of the `awaiting` ones: it is the one
|
||
// kind of waiting the linen room is meant to act on, so it also takes the rule down its
|
||
// left edge that every other flagged thing in the app wears.
|
||
<div key={r.id} className={orphan ? "tc-flag" : undefined} style={{ borderBottom: i === rows.length - 1 ? "none" : "1px solid var(--color-divider)", padding: "var(--space-3) var(--space-4)", paddingLeft: orphan ? "calc(var(--space-4) - 4px)" : "var(--space-4)", opacity: awaiting && !orphan ? 0.6 : 1 }}>
|
||
<div style={{ display: "flex", gap: "var(--space-3)", alignItems: "baseline", flexWrap: "wrap" }}>
|
||
<span style={{ fontSize: 12, color: "var(--color-neutral-700)", width: 74, flex: "none" }}>{r.code}</span>
|
||
<b style={{ flex: 1, minWidth: 180 }}>
|
||
{r.summary}
|
||
<span style={{ fontWeight: 400, color: "var(--color-neutral-700)" }}> · {r.staffName}{r.ward ? ` (${r.ward})` : ""}</span>
|
||
</b>
|
||
{orphan && <span className="tag tag-flag">No approver</span>}
|
||
<span className={NEEDS_STAFF.has(r.status as never) ? "tag tag-accent" : "tag tag-neutral"}>{st.label}</span>
|
||
<button className="btn btn-ghost" style={{ minHeight: 30, padding: "2px 10px" }} onClick={() => { setOpenId(isOpen ? null : r.id); setReply(""); setHold(""); setReassign(""); }}>
|
||
{isOpen ? "Close" : `Open${r.lineCount > 1 ? ` · ${r.lineCount} lines` : ""}`}
|
||
</button>
|
||
</div>
|
||
|
||
<div style={{ fontSize: 12.5, color: "var(--color-neutral-700)", marginTop: 4 }}>
|
||
{[r.reason, approval, r.raisedByName ? `raised by ${r.raisedByName}` : "", formatInZone(r.createdAt, s.tz)].filter(Boolean).join(" · ")}
|
||
</div>
|
||
|
||
{isOpen && (
|
||
<div style={{ marginTop: "var(--space-3)", paddingLeft: "var(--space-4)", borderLeft: "2px solid var(--color-text)" }}>
|
||
<LineList r={r} />
|
||
{r.note && <p style={{ fontSize: 13.5, lineHeight: 1.6, margin: "0 0 var(--space-3)", maxWidth: "70ch" }}>“{r.note}”</p>}
|
||
|
||
{awaiting ? (
|
||
<>
|
||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--color-neutral-700)", margin: 0, maxWidth: "70ch" }}>
|
||
{orphan
|
||
? <>Nobody has been asked to approve this one — choose somebody who can.</>
|
||
: <>Waiting on {r.managerName}. If that answer is never coming, send it to somebody else or withdraw it.</>}
|
||
</p>
|
||
{/* Without these two, a request addressed to a manager who never claimed an
|
||
account waits for ever: the wearer has no op that touches it and the manager
|
||
cannot sign in to decide it. */}
|
||
<div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap", alignItems: "center", marginTop: "var(--space-3)" }}>
|
||
<select className="input" style={{ width: 260 }} aria-label={orphan ? `Choose who approves ${r.code}` : `Send ${r.code} to a different approver`} value={reassign} onChange={(e) => setReassign(e.target.value)}>
|
||
<option value="">{orphan ? "Choose an approver…" : "Send it to somebody else…"}</option>
|
||
{/* Split only when there is something to split off — two headings over one
|
||
undivided list of people who can all decide it today is furniture. */}
|
||
{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="Can’t be asked — no staff-app account">
|
||
{unreachable.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
|
||
</optgroup>
|
||
</>
|
||
)}
|
||
</select>
|
||
<Btn primary label={orphan ? "Ask them" : "Re-address"} onClick={async () => { if (reassign && await act("request.reassign", { id: r.id, managerId: reassign })) setReassign(""); }} />
|
||
<Btn label="Withdraw it" onClick={() => { if (confirm(`Withdraw ${r.code}? ${r.staffName} is told it was declined by the linen room.`)) act("request.withdraw", { id: r.id, reason: "Withdrawn — no approver available" }); }} />
|
||
{orderForm}
|
||
</div>
|
||
{/* A facility that has only just loaded its register has nobody on it who can
|
||
approve anything, and an empty dropdown beside a primary button reads as
|
||
a screen that is broken rather than as a register that is short. */}
|
||
{choices.length === 0 && (
|
||
<p style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-neutral-700)", margin: "var(--space-2) 0 0", maxWidth: "70ch" }}>
|
||
Nobody on the register can approve this one — add the ward's managers on{" "}
|
||
<Link href="/app/staff">the register</Link>, or withdraw it.
|
||
</p>
|
||
)}
|
||
{/* Between choosing a name and pressing the button, which is the only moment
|
||
either of these can still be acted on. Afterwards the self-approval is on
|
||
the record, and the unreachable one is a request in Open under the name of
|
||
somebody who cannot answer it, with nothing anywhere saying so. */}
|
||
{picked && (picked.id === r.staffId || !picked.selfEmail) && (
|
||
<p style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-neutral-700)", margin: "var(--space-2) 0 0", maxWidth: "70ch" }}>
|
||
{picked.id === r.staffId && (
|
||
<>This is {picked.first}'s own request — sending it to them is a self-approval.{" "}</>
|
||
)}
|
||
{!picked.selfEmail && (
|
||
<>
|
||
{picked.first} has no staff-app account, so can't be asked.{" "}
|
||
{picked.selfCode && slipLive(picked.selfCodeAt, s.today, s.tz)
|
||
? <>A code on <Link href={`/app/staff/${picked.id}`}>their record</Link> hasn't been used yet.</>
|
||
: picked.selfCode
|
||
? <>The code on <Link href={`/app/staff/${picked.id}`}>their record</Link> has expired — make a new one first.</>
|
||
: <>Give {picked.first} a code on <Link href={`/app/staff/${picked.id}`}>their record</Link> first.</>}
|
||
</>
|
||
)}
|
||
</p>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap", alignItems: "center" }}>
|
||
{r.status === "accepted" && <Btn primary label="Start picking" onClick={() => act("request.pick", { id: r.id })} />}
|
||
{r.status === "picking" && (
|
||
<>
|
||
<input className="input" style={{ width: 200 }} aria-label={`How long ${r.code} is held at the counter`} placeholder="Held until — e.g. Fri 6pm" value={hold} onChange={(e) => setHold(e.target.value)} />
|
||
<Btn primary label="Hold at the counter" onClick={async () => { if (await act("request.hold", { id: r.id, holdUntil: hold })) setHold(""); }} />
|
||
<Btn label="Send on the ward round" onClick={() => act("request.round", { id: r.id })} />
|
||
</>
|
||
)}
|
||
{r.status === "ready" && (
|
||
<>
|
||
{/* One code for the whole bag, so the number of garments it covers is said
|
||
beside it — otherwise the person at the counter reads out a code, hands
|
||
over one garment and both of them think that was the lot. */}
|
||
<span style={{ fontSize: 13 }}>Code <b style={{ fontFamily: "monospace", fontSize: 15 }}>{r.collectCode}</b> · {plural(r.garments, "garment", "garments")}{r.holdUntil ? ` · until ${r.holdUntil}` : ""}</span>
|
||
<Btn primary label="Collected" onClick={() => act("request.collected", { id: r.id })} />
|
||
</>
|
||
)}
|
||
{r.status === "round" && <span style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>On the round to {r.ward || "the ward"} — {plural(r.garments, "garment", "garments")}. The ward desk signs for it.</span>}
|
||
{r.status === "delivered" && <span style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>Signed by {r.signerName}{r.signerRole ? `, ${r.signerRole}` : ""}{r.claimedAt ? " · collected by the requester" : " · not yet collected from the ward"}</span>}
|
||
{r.status === "collected" && <span style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>Handed over at the counter.</span>}
|
||
{r.status === "declined" && <span style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>Declined — {r.declineReason || "no reason recorded"}.</span>}
|
||
{/* The paper that travels with the bag. It lists the approved lines and the
|
||
collection code, so what is signed for is what was picked. */}
|
||
{r.status !== "declined" && (
|
||
<>
|
||
<Btn label={onRound(r) ? "Delivery slip" : "Collection slip"} onClick={() => openSlip(onRound(r) ? "delivery" : "collection", slipFor(r))} />
|
||
{/* Nothing was ordered on a request every line of which was refused, so a
|
||
declined one gets no form — the same rule as the slip beside it. */}
|
||
{orderForm}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="sec" style={{ marginTop: "var(--space-4)" }}>Messages</div>
|
||
{r.messages.length === 0 && <div style={{ fontSize: 13, color: "var(--color-neutral-700)", padding: "var(--space-2) 0" }}>Nothing asked about this one.</div>}
|
||
{r.messages.map((m) => (
|
||
<div key={m.id} style={{ padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13.5 }}>
|
||
<b>{m.fromStaff ? m.authorName : `${m.authorName} (linen room)`}</b>
|
||
<span style={{ color: "var(--color-neutral-700)", marginLeft: 8, fontSize: 12 }}>{formatInZone(m.at, s.tz)}</span>
|
||
<div style={{ marginTop: 3, lineHeight: 1.6 }}>{m.body}</div>
|
||
</div>
|
||
))}
|
||
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", flexWrap: "wrap" }}>
|
||
<input className="input" style={{ flex: 1, minWidth: 220 }} aria-label={`Reply about ${r.code}`} placeholder="Reply to this order" value={reply} onChange={(e) => setReply(e.target.value)} />
|
||
<Btn label="Send" onClick={async () => { if (reply.trim() && await act("request.reply", { id: r.id, body: reply })) setReply(""); }} />
|
||
</div>
|
||
|
||
<div className="sec" style={{ marginTop: "var(--space-4)" }}>History</div>
|
||
{r.events.map((e) => (
|
||
<div key={e.id} style={{ display: "flex", gap: "var(--space-3)", padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13 }}>
|
||
<span style={{ flex: 1 }}>{e.label}{e.meta ? ` — ${e.meta}` : ""}</span>
|
||
<span style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>{e.actorName} · {formatInZone(e.at, s.tz)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
{/* The queue is the newest few hundred requests and no more. Unsaid, a tab that has reached
|
||
the ceiling looks exactly like a tab that holds everything — the counts on the buttons
|
||
included — and somebody hunting a request from last winter concludes it was never
|
||
raised. The wearer's own record keeps its history separately, which is where a request
|
||
older than this cut-off is actually found. */}
|
||
{data.moreRequests && (
|
||
<p style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-neutral-700)", marginTop: "var(--space-4)", maxWidth: "70ch" }}>
|
||
Only the most recent {data.requestLimit} requests are loaded — older ones are on{" "}
|
||
<Link href="/app/staff">the wearer's staff record</Link>.
|
||
</p>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
</section>
|
||
);
|
||
}
|