Files
threadcount-community/app/app/staff/[id]/page.tsx
T
ThreadCount 18514c0ee8 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 9fc2edb on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 13:02:36 +10:00

1003 lines
76 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";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { PageHead, Empty, ErrorLine, Field, LiveRegion, th } from "@/components/ui";
import { HandInDialog, PhotoButton, ReturnDialog, printAccessSlip, printCreditSlip, printHandInReceipt } from "@/components/dialogs";
import { viewPhoto } from "@/lib/photo";
import { statusText } from "@/lib/staffreq";
import { CASUAL_ALLOWED, FTE_CASUAL, FTE_OPTIONS, SLIP_DAYS, UNIFORM_STYLES, allowanceRouteOf, approvalDeparture, approvalRemaining, capCheck, ccOf, daysBetween, entOf, facilityDate, facilityToday, fmtDate, initialGarments, initialRemaining, initialSets, initialUsed, isNursing, issueCost, label, money, setsForFte, slipLive, statusTag, type ApprovalRec, type IssueRec, type StaffRec } from "@/lib/compute";
/** "1 top" / "2 tops", said the way somebody would say it across the counter. */
const pl = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
/* The few fields of a staff request this screen needs off /api/requests: enough to say what the
* form was for, to print it again, and to say who decided it. Asked for one person at a time; the
* linen room's queue reads the same rows unscoped, for the whole facility. */
type FormReq = {
id: string; code: string; status: string; staffId: string; createdAt: string;
garments: number; lineCount: number; reason: string;
/** Both only feed statusText, which says "with Jo Barnes" or gives a decline its reason. */
managerName: string; declineReason: string | null;
/** Who that manager is on the register. A manager may approve a request raised for herself, and
* this beside the subject's id is the only thing that can show it happened — the two names
* would be identical whoever signed, which is the whole reason the approval row keeps an id and
* not just a spelling. Null where nobody was ever addressed, which is not the same as nobody
* having approved it. */
managerId: string | null;
};
/** A request whose approver is the person it is for, said in the words of where it has got to. It
* only becomes a self-approval once they have actually decided it; until then it is theirs to
* decide, and calling that "self-approved" would put an approval on the record a day early. */
const selfTag = (status: string) =>
status === "awaiting" ? "Theirs to approve" : status === "declined" ? "Declined by themselves" : "Self-approved";
/** One line of the form history: an approval off the snapshot, or a request off that fetch. Kept
* as a union rather than one loose shape so a row cannot be built with neither behind it. */
type FormRow =
| { kind: "approval"; on: string; a: ApprovalRec }
| { kind: "request"; on: string; r: FormReq };
/* One section of the record: a named head, a bordered body, one subject inside it.
*
* This is the busiest screen in the product — details, what they hold, approvals, order forms, notes,
* pickups and staff-app access down one side, alterations, hand-ins, issues and orders down the
* other — and until now the only thing between two of them was a rule and a word. Where one ends
* and the next begins has to be visible from across the counter without reading the headings.
*
* `aside` carries the one action a section owns — printing the order form, recording a hand-in —
* which belongs beside its own heading rather than loose among the rows underneath it.
*
* Declared out here rather than inside the page: a component defined during a render is a new type
* on every keystroke, so React would tear down and rebuild whatever is inside it — and the caret in
* the notes box would go with it. */
function Panel({ title, aside, flag, children }: { title: React.ReactNode; aside?: React.ReactNode; flag?: boolean; children: React.ReactNode }) {
return (
<div className={"tc-panel" + (flag ? " tc-flag" : "")} style={{ marginBottom: "var(--space-4)" }}>
{/* The aside wears its own class even when it holds a button: the head is small caps with a
tenth of an em between the letters, and both of those inherit — a bare button in here
would come out shouting PRINT ORDER FORM. */}
<div className="tc-panel-head"><span>{title}</span>{aside && <span className="tc-panel-aside">{aside}</span>}</div>
<div className="tc-panel-body">{children}</div>
</div>
);
}
export default function StaffProfile() {
const { id } = useParams<{ id: string }>();
const { s, isAdmin, mutate } = useSnap();
const { byId } = useDerived();
const st = s.staff.find((x) => x.id === id);
const [edit, setEdit] = useState(false);
const [f, setF] = useState({ first: "", last: "", phone: "", top: "", pants: "", ent: "", group: "", dept: "", ccOverride: "", start: "", uniformStyle: "" });
const [err, setErr] = useState("");
/* `date` is the day the manager signed, which is not the day somebody got round to typing it up;
`note` is whatever they wrote on the sheet beside the number. The date starts at the facility's
own today off the snapshot rather than the browser's clock, so the field reads the same on the
server's first paint as it does after hydration. */
const [ap, setAp] = useState<{ sets: string; fte: string; date: string; note: string }>(() => ({ sets: "", fte: "", date: s.today, note: "" }));
/* Who signed is not held here: a signed form is recorded as signed by the manager on the register
(apMgr, below), which is saved on the record the moment it is picked — see ManagerBox. */
const [apPhoto, setApPhoto] = useState<string | null>(null);
const [alt, setAlt] = useState({ garment: "", desc: "" });
const [notes, setNotes] = useState<string | null>(null);
const [ret, setRet] = useState<IssueRec | null>(null);
const [handin, setHandin] = useState(false);
const [hiMsg, setHiMsg] = useState("");
const [offMsg, setOffMsg] = useState("");
const [reqForms, setReqForms] = useState<FormReq[] | null>(null);
const [formsErr, setFormsErr] = useState("");
const [formsCapped, setFormsCapped] = useState("");
const notesTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingNotes = useRef<{ id: string; v: string } | null>(null);
/* The other half of this person's order-form history: the requests raised in the app for them.
*
* Requests are deliberately not in the snapshot — a busy facility accumulates them without
* limit — so they are asked for here, for this person by name. Asking for the whole queue and
* keeping the handful that are theirs meant carrying 400 requests with every line, message and
* event on each to answer a question about one nurse, and it meant her older forms fell off the
* end of those 400 and were simply not on her record any more.
*
* It lands after the record has already painted, because the approvals come out of the snapshot
* and are readable straight away. A fetch that fails says so out loud: a history quietly missing
* half of itself reads as "no form was ever raised for her", which is a different fact entirely
* — and so does a history that stops at a ceiling without mentioning one, which is what the
* server's count is reported back for. */
useEffect(() => {
let live = true;
// The last person's answer is cleared as the id changes, with the drafts and everything else
// about them — see the block below, which runs while this record is being painted rather than
// after. Clearing it here instead meant a warning raised against somebody else's history was on
// screen for the frame that first drew this person's name at the top of it.
(async () => {
try {
const r = await fetch(`/api/requests?staff=${encodeURIComponent(id)}`);
if (!r.ok) throw new Error("no");
const j = (await r.json()) as { requests?: FormReq[]; requestLimit?: number; moreRequests?: boolean };
if (!live) return;
const got = j.requests ?? [];
setReqForms(got);
setFormsCapped(j.moreRequests ? `Showing the ${j.requestLimit ?? got.length} most recent requests raised for them — older ones arent listed.` : "");
} catch {
if (!live) return;
setReqForms([]);
setFormsCapped("");
setFormsErr("The requests raised for them couldnt be loaded, so only the approvals are listed here.");
}
})();
return () => { live = false; };
}, [id]);
/* Flush (not discard) an unsaved note if the user leaves within the debounce window — off the
* screen altogether, or onto another person's record through the list of who they approve for.
* That second way keeps this screen mounted, and there is only one debounce slot: the next
* person typing in their own notes box would cancel the timer the last person's note is still
* waiting on, and it would simply never be saved. Sent here to the record it was typed on. */
useEffect(() => () => { if (notesTimer.current) clearTimeout(notesTimer.current); const p = pendingNotes.current; if (p) { pendingNotes.current = null; fetch("/api/mutate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ op: "staff.patch", payload: { id: p.id, notes: p.v } }), keepalive: true }).catch(() => {}); } }, [id]);
/* Everything held above is about one person, and until "Whose requests they approve" there was no
way from one record to another except back through the register — which takes this screen down
and empties all of it on the way. A link from one record straight to the next does not: same
route, same component, new id. Left alone, a half-filled approval, an open edit form, a note
typed a second ago or a hand-in dialog would still be sitting here over somebody else's record,
and the button beside each of them saves to whichever record is on screen — so an approval
typed off Jane's form would be recorded against the nurse whose name is now at the top.
Cleared as the id changes rather than after it has painted, so none of the last person is ever
shown against this one — the order forms fetched for them included, which used to be emptied by
its own effect a frame later and so painted once under the new name. */
const [shownFor, setShownFor] = useState(id);
if (shownFor !== id) {
setShownFor(id);
setReqForms(null); setFormsErr(""); setFormsCapped("");
setEdit(false); setErr(""); setOffMsg(""); setHiMsg("");
setAp({ sets: "", fte: "", date: s.today, note: "" }); setApPhoto(null);
setAlt({ garment: "", desc: "" });
setNotes(null); setRet(null); setHandin(false);
}
if (!st) return <section><PageHead eyebrow="People" title="Staff member not found" /><Empty><Link href="/app/staff"> Staff Register</Link></Empty></section>;
/* What the reports measure a year's drawing against, and nothing else. It turns nobody away at the
counter, which is why it is a line in Details beside the field that sets it rather than the
subject of a panel: what somebody may hold is worked out below, and has no year in it. */
const of = entOf(s, st), limited = Number.isFinite(of);
const nursing = isNursing(s, st);
/* What this person is holding right now, and what the counter would therefore do — asked of the
same function the counter asks, so this record and the answer at the counter cannot come out as
two different numbers. Six sets is what somebody has on their back and in their locker at any
time: no date goes into it and nothing about it starts again in July.
The ceiling bites on each half — six tops and six pairs — because the smaller of the two counts
is not a ceiling on its own, so the room left is counted in halves too.
What it counts as held includes what is on order for them or waiting at the counter, because the
counter counts it. owedN is that part, so the sentence that says what they hold can say how much
of it they have not had yet — "holding four tops" to somebody with two in their locker is a
figure nobody believes without the rest of it. */
const held = capCheck(s, st);
const owedN = held.owed.tops + held.owed.pants + held.owed.other;
const roomTops = Math.max(0, held.cap - held.tops), roomPants = Math.max(0, held.cap - held.pants);
const pct = Math.min(100, Math.round((held.sets / Math.max(held.cap, 1)) * 100));
/* Sets a manager has signed for that nobody has collected yet. It is what an issue draws down, and
it is not a second ceiling — a signature does not lift the six sets, and quoting it as though it
did would promise garments the counter is about to refuse. */
const apLeft = approvalRemaining(s, st.id);
/* The initial kit: what somebody is owed on starting, which is a different question again from
what they may hold. It is a lifetime one — has this person been kitted out yet — so it counts
every issue they have ever had rather than this year's, and a hand-in doesn't give it back.
null is "there is no kit to count", and it is not zero. For a casual, or somebody on the FTE
table whose FTE has never been recorded, it means nobody can say yet; for the groups on manager
approval it means they start with nothing and it is their manager who says. Printing a 0 would
read as a refusal no manager ever wrote, so each of those says so in its own words below. */
const fte = st.fte || "";
/* "Casual" is a column of the FTE table, and the one, two or three sets it leaves to the manager
are that table's numbers. A casual in a group on the starting kit or on manager approval is on
that route like everybody else in the group, so the word on its own decides nothing here. */
const casual = nursing && fte.trim().toLowerCase() === FTE_CASUAL.toLowerCase();
/* A record with no staff group on it at all — half typed in, or imported from a list that never
had the column. It is not a stream, and reading it as one had this screen telling a coordinator
"no initial kit for this group" about a record with no group in it to name. */
const noGroup = !(st.group || "").trim();
/* Manager approval: every group the facility has put on neither the FTE table nor the starting
kit. They start with no kit, and FTE has nothing to do with what they are owed; their manager
signs for each set, up to the same six anybody may hold. Asked of the facility's own lists, the
same answer the counter and the printed form reach, so this record cannot tell somebody they are
owed a kit the counter would turn them away for. */
const onApproval = !noGroup && allowanceRouteOf(s, st) === "approval";
const kitSets = initialSets(s, st), kitOf = initialGarments(s, st);
const kitUsed = initialUsed(s, st.id), kitLeft = initialRemaining(s, st);
const ccCodes = [...new Set(s.depts.map((d) => d.cc).filter(Boolean))];
/* Newest first by the date on each row — the day the manager signed, which is what these rows
show and sort on. They arrive in the order they were typed in, and a fortnight of forms comes
down to the linen room in one envelope: reversing that alone put Monday's signature above the
one from three weeks earlier only while everything was still being stamped with today. Two
forms signed the same day keep the order they were recorded, the later one first. */
const approvals = s.approvals.filter((a) => a.staffId === st.id).reverse()
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
const alterations = s.alterations.filter((a) => a.staffId === st.id);
const issues = s.issues.filter((i) => i.staffId === st.id).sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : b.createdAt.localeCompare(a.createdAt)));
const orders = s.orders.filter((o) => o.staffId === st.id);
const handins = s.handins.filter((h) => h.staffId === st.id);
const pickups = s.pickups.filter((p) => p.staffId === st.id && !p.pickedUp);
/* The people this person approves for, which the register only ever held from the other end: it is
every record that names them, not a list kept on theirs. Sorted by surname the way the register
itself is, so a coordinator reads the two the same way round. Inactive reports are left out for
the same reason staff.patch counts only active ones — somebody off the register raises nothing. */
const reports = s.staff.filter((x) => x.managerId === st.id && !x.inactive)
.sort((a, b) => `${a.last} ${a.first}`.localeCompare(`${b.last} ${b.first}`));
/* Every order form this person has on file, newest first, and there are two kinds of them. An
approval is a decision already made in ink and typed back in here; a request is an ask raised
in the app that the ward may still refuse. A reader must never have to work out which one a row
is from the wording, so each row is labelled — the two carry different weight in an audit, and
a request nobody approved sitting unlabelled among signed approvals is the one way this list
could mislead. Both dates are read in the facility's own zone so a form raised late on a
Tuesday evening does not file itself under Wednesday. */
const forms: FormRow[] = [
...approvals.map((a) => ({ kind: "approval" as const, on: a.date, a })),
// Already this person's — the fetch above asks for theirs by name. The check stays because the
// cost of it is nothing and the cost of being wrong is somebody else's uniform history on this
// person's record.
...(reqForms ?? []).filter((r) => r.staffId === st.id).map((r) => ({ kind: "request" as const, on: facilityDate(r.createdAt, s.settings.timezone), r })),
].sort((x, y) => (x.on < y.on ? 1 : x.on > y.on ? -1 : 0));
const startEdit = () => { setF({ first: st.first, last: st.last, phone: st.phone, top: st.top, pants: st.pants, ent: st.ent === null ? "" : String(st.ent), group: st.group, dept: st.dept, ccOverride: st.ccOverride, start: st.start, uniformStyle: st.uniformStyle }); setErr(""); setOffMsg(""); setEdit(true); };
const editNursing = isNursing(s, { ...st, group: f.group });
async function save() {
if (!f.first.trim() || !f.last.trim()) return;
const r = await mutate("staff.save", { id: st!.id, num: st!.num, ...f, ent: f.ent === "" ? null : parseInt(f.ent, 10) || 0, notes: st!.notes });
if (!r.ok) { setErr(r.error); return; }
setEdit(false);
}
// offMsg goes with err: it reports what one particular click closed, so leaving it up while the
// coordinator edits sizes or takes a hand-in reads as if those closed requests too.
async function act(op: string, payload: unknown) { setErr(""); setOffMsg(""); const r = await mutate(op, payload); if (!r.ok) setErr(r.error); return r.ok; }
/* Taking somebody off the register also declines whatever they were still waiting on — see
* staff.patch in lib/ops.ts, which closes those requests in the same transaction so they don't
* sit in a manager's queue forever. That is somebody's pending ask being cancelled on this
* click, and nobody is emailed about it, so the count comes back with the write and gets said
* out loud here: the ward has to raise it again under a name still on the register, or the
* garments simply never arrive. */
async function deactivate() {
setErr(""); setOffMsg("");
const r = await mutate<{ closedRequests?: number }>("staff.patch", { id: st!.id, inactive: true });
if (!r.ok) { setErr(r.error); return; }
const n = r.result?.closedRequests ?? 0;
if (n) setOffMsg(`${n} request${n === 1 ? "" : "s"} ${n === 1 ? "was" : "were"} still waiting on approval and ${n === 1 ? "has" : "have"} been closed. The ward will need to raise ${n === 1 ? "it" : "them"} again for somebody still on the register.`);
}
function saveNotes(v: string) {
setNotes(v);
pendingNotes.current = { id: st!.id, v };
if (notesTimer.current) clearTimeout(notesTimer.current);
notesTimer.current = setTimeout(() => { pendingNotes.current = null; act("staff.patch", { id: st!.id, notes: v }); }, 600);
}
/* What this approval will actually be filed against, worked out the way lib/ops.ts works it out:
the FTE typed off the paper if there is one, otherwise the one on the record. The screen has to
reach the same answer as the server, or it stays quiet about a departure the server is about to
write down.
The departure sentence itself is approvalDeparture(), the one approval.add writes onto the row,
so a coordinator sees it coming while the number is still being typed and what they see is word
for word what gets recorded. Where the server has already written one, the approvals list shows
what was recorded and doesn't ask again — what was signed in March is what March's note says,
not what today's table would make of it. */
const apFte = ap.fte.trim() || fte;
/* The manager on the register is the person who signs the paper form as well as the one who
approves in the app — one box, not two — so a signed form is recorded as theirs, by the name the
register holds and linked. Only somebody still on the register: a manager who has left signs
nothing new, and the form waits until a new one is set. */
const mgr = st.managerId ? s.staff.find((x) => x.id === st.managerId) : undefined;
const apMgr = mgr && !mgr.inactive ? mgr : undefined;
const apByName = apMgr ? `${apMgr.first} ${apMgr.last}`.trim() : "";
const apOver = approvalDeparture({ sets: parseInt(ap.sets, 10) || 0, fte: apFte, by: apByName || "the manager" });
const apProposed = setsForFte(apFte);
// A form cannot have been signed tomorrow, and the usual cause is a mistyped year. Refusing the
// date is not refusing the form — the paper is still on the counter and the year is two keystrokes
// — whereas a 2027 approval sorts to the top of this record for the next fifteen months.
//
// Only what is being typed here, and deliberately not a claim about what is on file. A form dated
// ahead of today arrives by restored backup or by import, where nothing asks this screen's
// opinion, so the rows below say when one is dated ahead rather than pretending it cannot be.
const apFuture = ap.date > s.today;
const apInvalid = !apMgr || !(parseInt(ap.sets, 10) > 0) || apFuture;
/* The order form, opened in its own tab the way the label sheet is: this screen is what the
coordinator is working from and they come back to it to record the signature. Everything on the
form that names the facility — heading, org line, logo, footer contacts — is read from settings
there, so nothing on this screen has to know who the customer is.
One window, three things to print, and the query string is the whole difference. `staff` is a
fresh form part-filled from this record with the garment rows left blank for the manager to write
on; `request` is the same form with the garments of an ask already in the app on it; `approval`
is a copy of a decision already recorded, printed from the approval row rather than from this
record, because what was signed in March is not what today's record would propose. */
const openForm = (qs: string) => window.open(`/print/order-form?${qs}`, "_blank", "noopener");
const printOrderForm = () => openForm(`staff=${encodeURIComponent(st.id)}`);
const Row = ({ k, v }: { k: string; v: React.ReactNode }) => <div style={{ display: "flex", justifyContent: "space-between", gap: "var(--space-3)", padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13 }}><span style={{ color: "var(--color-neutral-700)", flex: "none" }}>{k}</span><b style={{ textAlign: "right" }}>{v}</b></div>;
return (
<section>
<div style={{ padding: "var(--space-4) 0 0" }}><Link href="/app/staff" className="btn btn-ghost"> Staff Register</Link></div>
{/* The same ink band every other screen wears, rather than this one page's own rule under a
heading: who the record is for is read from the doorway, and the person's name is the one
thing on it that has to carry that far. */}
<PageHead
eyebrow={`People · ${st.num}`}
title={`${st.first} ${st.last}`}
below={<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-2)" }}><span className="tag tag-outline">{st.group}</span><span className={st.inactive ? "tag tag-accent" : "tag tag-neutral"}>{st.inactive ? "Inactive" : "Active"}</span></div>}
>
{isAdmin && (
!edit ? (
<>
{!st.inactive ? <button className="btn btn-ghost" onClick={deactivate}>Deactivate</button> : <button className="btn btn-secondary" onClick={() => act("staff.patch", { id: st.id, inactive: false })}>Reactivate</button>}
{issues.length === 0 && orders.length === 0 && <button className="btn btn-ghost" onClick={() => { if (confirm(`Delete ${st.first} ${st.last} from the register?`)) act("staff.delete", { id: st.id }).then((ok) => { if (ok) window.location.href = "/app/staff"; }); }}>Delete</button>}
<button className="btn btn-primary" onClick={startEdit}>Edit details</button>
</>
) : (
<>
<button className="btn btn-ghost" onClick={() => setEdit(false)}>Cancel</button>
<button className="btn btn-primary" onClick={save} disabled={!f.first.trim() || !f.last.trim()}>Save changes</button>
</>
)
)}
</PageHead>
<ErrorLine msg={err} />
<LiveRegion msg={offMsg} style={{ marginTop: "var(--space-2)", fontSize: 13, fontWeight: 600, lineHeight: 1.55 }} />
{/* No top margin of its own: the page head already lays a space-6 gap under the ink band, and
two of them put the first panel adrift halfway down the screen. */}
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "5fr 7fr", gap: "var(--space-6)" }}>
<div>
<Panel title="Details">
{!edit ? (
<>
<Row k="Phone" v={st.phone || "—"} />
<Row k="Department / ward" v={st.dept || "—"} />
<Row k="Cost centre" v={(ccOf(s, st) || "—") + (st.ccOverride ? " — override" : " — from department")} />
<Row k="Sizes" v={`top ${st.top || "—"} · pants ${st.pants || "—"}`} />
{/* Beside the sizes, because it is the same question asked one step earlier: which
garments the counter will offer this person at all. Blank is not a gap — it is
every record until somebody says otherwise — so it says what blank does rather
than sitting there as an em dash the coordinator has to interpret. */}
<Row k="Uniform style" v={st.uniformStyle || "Not set — offered every style"} />
{/* A reporting figure, and named as one. It is what the register and the monthly report
measure a year's drawing against; it is not what the counter allows, which is the
sets below. The groups on the FTE table are not measured against a year at all. */}
<Row k="Yearly report figure" v={limited ? `${of} garments` : "Not measured"} />
<Row k="Started" v={st.start ? fmtDate(st.start) : "—"} />
</>
) : (
<>
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-3)", marginTop: "var(--space-3)" }}>
{([["first", "First name"], ["last", "Last name"], ["phone", "Phone"], ["top", "Top size"], ["pants", "Pants size"]] as const).map(([k, lbl]) => (
<Field key={k} label={lbl} error={(k === "first" || k === "last") && !f[k].trim() ? "Needed — a record has to have a name." : undefined}>{(c) => <input {...c} className="input" value={f[k]} onChange={(e) => setF({ ...f, [k]: e.target.value })} />}</Field>
))}
{/* Which cut they are offered, saved with the rest of the details. Not set is on the
list and is where every record starts: it offers both cuts exactly as Either
does, and the difference is only that nobody has said yet — which is what the
register's own filter goes looking for. A value this build doesn't recognise is
still shown rather than silently swapped for blank, the way the FTE picker
shows a figure that is not on the table. */}
<Field label="Uniform style" hint="Which cut the counter offers them.">
{(c) => (
<select {...c} className="input" value={f.uniformStyle} onChange={(e) => setF({ ...f, uniformStyle: e.target.value })}>
<option value="">Not set every style</option>
{f.uniformStyle && !UNIFORM_STYLES.includes(f.uniformStyle) && <option value={f.uniformStyle}>{f.uniformStyle}</option>}
{UNIFORM_STYLES.map((v) => <option key={v}>{v}</option>)}
</select>
)}
</Field>
{/* The field used to read "Annual entitlement", and beside it for the FTE table's
groups "No limit — their manager's approvals". Neither is true: nothing here is
what the counter allows, and the FTE table is under the same ceiling as everybody
else. What it really sets is the figure the reports measure a year's drawing
against, so it says so — and says, once, what does decide a hand-over. The route
is named rather than an occupation, because which groups are on it is each
facility's own answer. */}
<Field label="Yearly report figure (garments)"
hint={editNursing ? undefined : "Reports only — the counter doesnt use it."}>
{(c) => editNursing ? <input {...c} className="input" value="Not measured on the FTE table" disabled /> : <input {...c} className="input" value={f.ent} onChange={(e) => setF({ ...f, ent: e.target.value.replace(/[^0-9]/g, "") })} placeholder={`Default ${s.settings.defaultEntitlement}`} />}
</Field>
<Field label="Staff group">{(c) => <select {...c} className="input" value={f.group} onChange={(e) => setF({ ...f, group: e.target.value })}>{!s.settings.staffGroups.includes(f.group) && <option value={f.group}>{f.group || "—"}</option>}{s.settings.staffGroups.map((g) => <option key={g}>{g}</option>)}</select>}</Field>
<Field label="Department / ward">{(c) => <select {...c} className="input" value={f.dept} onChange={(e) => setF({ ...f, dept: e.target.value })}>{!s.depts.find((d) => d.name === f.dept) && <option value={f.dept}>{f.dept || "—"}</option>}{s.depts.map((d) => <option key={d.id} value={d.name}>{d.name}{d.cc ? ` (${d.cc})` : ""}</option>)}</select>}</Field>
<Field label="Start date">{(c) => <input {...c} className="input" type="date" value={f.start} onChange={(e) => setF({ ...f, start: e.target.value })} />}</Field>
<Field label="Cost centre override" style={{ gridColumn: "1 / -1" }}>{(c) => <select {...c} className="input" value={f.ccOverride} onChange={(e) => setF({ ...f, ccOverride: e.target.value })}><option value="">None derived from department</option>{ccCodes.map((x) => <option key={x} value={x}>{x}</option>)}{f.ccOverride && !ccCodes.includes(f.ccOverride) && <option value={f.ccOverride}>{f.ccOverride}</option>}</select>}</Field>
</div>
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: "var(--space-2)" }}>The staff number can&apos;t be changed.</div>
</>
)}
</Panel>
{/* Past the ceiling is not shouted in a second red — the bar below it is already the
accent. The panel takes the rule down its edge, the figure goes heavy, and the mark
says which way to read it. */}
<Panel title="Uniform held and owed" flag={held.over}>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--space-3)", fontSize: 13 }}>
<span>{held.sets} of {held.cap} sets held</span>
<b style={held.over ? { color: "var(--color-accent-700)", fontWeight: 800 } : undefined}>
{held.over
? <><span className="tc-mark" aria-hidden="true" />{pl(held.overBy, "garment", "garments")} past the ceiling</>
: roomTops === 0 && roomPants === 0 ? "At the ceiling"
: roomTops === roomPants ? `Room for ${pl(roomTops, "more set", "more sets")}`
: `Room for ${pl(roomTops, "top", "tops")}, ${pl(roomPants, "pair", "pairs")}`}
</b>
</div>
<div className="bar-track" style={{ height: 12, marginTop: "var(--space-2)" }}><div className="bar-fill" style={{ width: pct + "%", background: held.over ? "var(--color-accent)" : "var(--color-text)" }} /></div>
<div style={{ fontSize: 12.5, color: "var(--color-neutral-700)", lineHeight: 1.55, marginTop: "var(--space-2)" }}>
Holding {pl(held.tops, "top", "tops")} and {pl(held.pants, "pair", "pairs")}{held.other ? <>, plus {pl(held.other, "garment", "garments")} outside a set</> : null}{owedN > 0 ? <> {owedN} still on order or waiting at the counter</> : null}.
</div>
{/* The counter's own answer, said before anybody walks down to it. A number promised here
that the counter then refuses is the whole reason this panel was rebuilt, so what it
quotes is the room left against the ceiling and nothing else. With room on both halves
the heading above already says how much, and nothing more is added. */}
{(held.over || roomTops === 0 || roomPants === 0) && (
<div style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-neutral-800)", marginTop: "var(--space-2)" }}>
{held.over
? <>Nothing more goes over the counter without a coordinator override.</>
: roomTops === 0 && roomPants === 0
? <>The next top or pair needs a hand-in first, or a coordinator override.</>
: <>{roomTops === 0 ? "Another top" : "Another pair"} needs a hand-in first, or a coordinator override.</>}
</div>
)}
<div style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-neutral-800)", marginTop: "var(--space-2)" }}>
{apLeft > 0
? <><b>{pl(apLeft, "set", "sets")} signed for and not yet collected.</b></>
: <>Nothing signed for is waiting to be collected.</>}
</div>
{/* Saved the moment it changes, like the staff-app fields below: the FTE is copied off a
paper form somebody is holding, and making them find Edit details first is how it ends
up never being recorded at all. */}
<Field label="Combined FTE" style={{ width: 150, marginTop: "var(--space-4)" }}>
{(c) => (
<select {...c} className="input" value={fte} disabled={!isAdmin}
onChange={(e) => act("staff.patch", { id: st.id, fte: e.target.value })}>
<option value="">Not recorded</option>
{fte && !FTE_OPTIONS.includes(fte) && <option value={fte}>{fte}</option>}
{FTE_OPTIONS.map((v) => <option key={v}>{v}</option>)}
</select>
)}
</Field>
<div style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-neutral-800)", marginTop: "var(--space-2)" }}>
{kitOf === null
? noGroup
? <>No staff group recorded set one in Edit details.</>
: onApproval
? <>No initial kit for {st.group} their manager signs for each set.</>
: casual
? <>Casual their manager may sign for {CASUAL_ALLOWED} sets.</>
: <>No FTE recorded, so no initial kit is proposed.</>
: <>
<b>Initial kit {kitSets} set{kitSets === 1 ? "" : "s"} {kitOf} garments</b>{nursing ? <> at FTE {fte}</> : <> for {st.group}</>}: {pl(kitUsed, "garment", "garments")} issued, {kitLeft ? <>{kitLeft} still to come.</> : <>taken in full.</>}
</>}
</div>
</Panel>
{/* One section for the one person: the manager on the register approves this person's
requests in the app and signs their paper order form, so both live here, in one box.
Underneath it a signed form is recorded as that manager's. Keyed on the record, so
the search box and an open Change are emptied walking from one person to the next. */}
<Panel title="Managers approval" aside={<button className="btn btn-secondary" style={{ minHeight: 28, padding: "2px 10px" }} onClick={printOrderForm}>Print order form</button>}>
<ManagerBox key={st.id} people={s.staff} subject={st} isAdmin={isAdmin} act={act} />
<div style={{ borderTop: "1px solid var(--color-divider)", marginTop: "var(--space-4)", paddingTop: "var(--space-3)", fontSize: 13, fontWeight: 700 }}>Record a signed order form</div>
{/* The sets and the FTE are still typed off the paper. They are what the manager wrote,
not what the table proposes, and nothing here fills them in for them. */}
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", flexWrap: "wrap", alignItems: "flex-start" }}>
<Field label="Sets" style={{ width: 70 }}>{(c) => <input {...c} className="input" inputMode="numeric" value={ap.sets} onChange={(e) => setAp({ ...ap, sets: e.target.value.replace(/[^0-9]/g, "") })} />}</Field>
<Field label="FTE" style={{ width: 70 }}>{(c) => <input {...c} className="input" value={ap.fte} onChange={(e) => setAp({ ...ap, fte: e.target.value })} placeholder={fte || "1.0"} />}</Field>
{/* The day the manager signed it, not the day it was typed up. A fortnight of forms comes
down to the linen room in one envelope, and stamping the lot with today files an
approval weeks after the decision — then prints that wrong day back on the reprinted
form as the date it was signed. */}
<Field label="Date signed" style={{ width: 170 }} error={apFuture ? "That is after today, and a form cannot have been signed tomorrow — check the year." : undefined}>
{(c) => <input {...c} className="input" type="date" max={s.today} value={ap.date} onChange={(e) => setAp({ ...ap, date: e.target.value })} />}
</Field>
</div>
{/* What the manager wrote on the sheet, in their own words. The app writes its own sentence
when the number is above the table (below), but that is only arithmetic — it can say a
set was added and never why, and why is the whole of what anybody wants months later. */}
<Field label="Note on the form" style={{ marginTop: "var(--space-3)" }} hint="Optional — what the manager wrote beside the number.">
{(c) => <textarea {...c} className="input" rows={2} maxLength={400} placeholder="e.g. moving to nights, needs a fifth set" value={ap.note} onChange={(e) => setAp({ ...ap, note: e.target.value })} />}
</Field>
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", flexWrap: "wrap", alignItems: "center" }}>
<PhotoButton kind="approval" label="Photo the signed form" attached="Form photo attached" value={apPhoto} onChange={setApPhoto} onError={setErr} />
<button className="btn btn-secondary" disabled={apInvalid} onClick={async () => { if (!apMgr) return; if (await act("approval.add", { staffId: st.id, by: apByName, byStaffId: apMgr.id, sets: parseInt(ap.sets, 10), fte: ap.fte, date: ap.date, notes: ap.note.trim(), photoId: apPhoto })) { setAp({ sets: "", fte: "", date: s.today, note: "" }); setApPhoto(null); } }}>Record approval</button>
</div>
{!apMgr && (
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: "var(--space-2)" }}>
{mgr?.inactive ? "Their manager is inactive — set a new one first." : "Set their manager first."}
</div>
)}
{nursing && apProposed !== null && (
/* The table is a proposal, never a refusal — the form itself grants the manager discretion —
so this says what it works out to and gets out of the way. A bigger number is theirs
to sign for, and their name goes on the record beside it. */
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", lineHeight: 1.55, marginTop: "var(--space-2)" }}>
The table proposes {apProposed} set{apProposed === 1 ? "" : "s"} at FTE {apFte}.
</div>
)}
{apOver && (
/* Said before the button is pressed, because the sentence the server writes is the one
thing on this form nobody chose to write. A coordinator who can see it coming can put
the manager's own reason in the note beside it, which is the half the arithmetic can
never supply. Shown for a casual too: the form names one, two or three, and six signed
against it is as much a departure as six against a table that proposed five. */
<div style={{ fontSize: 12.5, color: "var(--color-neutral-800)", lineHeight: 1.55, marginTop: "var(--space-2)" }}>
<b>This will go on the record as written:</b> {apOver}
</div>
)}
{approvals.length === 0 && <div style={{ fontSize: 13, color: "var(--color-neutral-700)", padding: "var(--space-3) 0", borderBottom: "1px solid var(--color-divider)" }}>No approvals on file.</div>}
{/* What the server wrote on this row is what the row says. The departure is worked out
again here only where nothing was written — a form recorded before the app kept that
sentence — so a row never carries the app's wording and the server's side by side. */}
{approvals.map((a) => { const rem = a.sets - a.used, recorded = a.notes.trim(), over = recorded ? null : approvalDeparture(a), ahead = a.date > s.today; return (
<div key={a.id} style={{ padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)", fontSize: 13, flexWrap: "wrap" }}>
<span style={{ fontSize: 12, color: "var(--color-neutral-700)", flex: "none" }}>{fmtDate(a.date)}</span>
<span style={{ fontWeight: 600, flex: 1 }}>{a.by}{a.fte ? ` · FTE ${a.fte}` : ""}</span>
{/* A manager may sign for her own kit, and the link is the only thing that can prove
it happened — two spellings of a typed name never could. Anybody reading this
record months later has to see it here, beside the sets, or the approval reads as
an ordinary one somebody else signed. */}
{a.byStaffId === st.id && <span className="tag tag-accent" title={`${st.first} ${st.last} approved their own form`}>Self-approved</span>}
{/* A form cannot be signed tomorrow, but one dated ahead can still reach the record
— a restored backup and an import both take the date as given. Left unmarked it
simply sits at the top of the list for a year as though it were the newest thing
that happened. */}
{ahead && <span className="tag tag-outline" title="The date on this form is after today — usually a mistyped year.">Dated ahead of today</span>}
<span className={rem > 0 ? "tag tag-accent" : "tag tag-neutral"}>{rem > 0 ? `${rem} of ${a.sets} sets left` : "Fully collected"}</span>
{a.photoId && <button className="btn btn-ghost" style={{ minHeight: 26, padding: "2px 8px" }} onClick={() => viewPhoto(a.photoId!)}>Form</button>}
<button className="btn btn-ghost" style={{ minHeight: 26, padding: "2px 8px" }} onClick={() => printCreditSlip(s, st, a)}>Credit slip</button>
{isAdmin && <button className="btn btn-ghost btn-icon" title="Remove approval" aria-label={`Remove the ${fmtDate(a.date)} approval by ${a.by}`} onClick={() => { if (confirm("Remove this approval?")) act("approval.remove", { id: a.id }); }}>×</button>}
</div>
{/* The one sentence about this approval that is worth anything months later — the
manager's reason for the number, and after it the departure the app wrote down
itself, both as they were recorded that day. It was being recorded and shown on no
screen at all, which meant an extra set signed for in March looked, by June,
exactly like a typo nobody could account for. A row with nothing written on it is
from before that was kept: say what the numbers show and say that nobody wrote the
reason down, rather than leaving the departure unmentioned. */}
{(recorded || over) && (
<div style={{ fontSize: 12.5, color: "var(--color-neutral-800)", lineHeight: 1.55, marginTop: 4 }}>
{recorded || `${over} Nothing was written down about why.`}
</div>
)}
</div>
); })}
</Panel>
<Panel title="Previous order forms">
<LiveRegion msg={formsErr} style={{ fontSize: 12.5, fontWeight: 600, color: "var(--color-accent-700)" }} />
{forms.map((x) => x.kind === "approval" ? (
<div key={`a${x.a.id}`} style={{ display: "flex", alignItems: "center", gap: "var(--space-3)", padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13, flexWrap: "wrap" }}>
<span style={{ fontSize: 12, color: "var(--color-neutral-700)", flex: "none", minWidth: 82 }}>{fmtDate(x.on)}</span>
<span className="tag tag-outline" style={{ flex: "none" }}>Approval</span>
<span style={{ flex: 1, minWidth: 160 }}>{x.a.sets} set{x.a.sets === 1 ? "" : "s"} approved by {x.a.by}{x.a.fte ? ` · FTE ${x.a.fte}` : ""}</span>
{x.a.byStaffId === st.id && <span className="tag tag-accent">Self-approved</span>}
{/* Same as the approvals above: a form dated ahead of today is at the top of this list
until the date catches up, and saying so is the difference between a mistyped year
and the newest form on file. */}
{x.on > s.today && <span className="tag tag-outline" title="The date on this form is after today — usually a mistyped year.">Dated ahead of today</span>}
{x.a.photoId && <button className="btn btn-ghost" style={{ minHeight: 26, padding: "2px 8px" }} aria-label={`Open the signed form photographed on ${fmtDate(x.on)}`} onClick={() => viewPhoto(x.a.photoId!)}>Signed form</button>}
{/* That approval, not this person's form as it would print today. The two used to be
the same button, so looking up what was approved in March got March's date at the
top of today's FTE, today's ward and today's proposal — a document nobody ever
signed, handed over as the one they did. */}
<button className="btn btn-ghost" style={{ minHeight: 26, padding: "2px 8px" }} aria-label={`Print the approval recorded ${fmtDate(x.on)} as it was recorded`} onClick={() => openForm(`approval=${encodeURIComponent(x.a.id)}`)}>Print the form</button>
</div>
) : (
<div key={`r${x.r.id}`} style={{ display: "flex", alignItems: "center", gap: "var(--space-3)", padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13, flexWrap: "wrap" }}>
<span style={{ fontSize: 12, color: "var(--color-neutral-700)", flex: "none", minWidth: 82 }}>{fmtDate(x.on)}</span>
<span className="tag tag-neutral" style={{ flex: "none" }}>Request</span>
<span style={{ flex: 1, minWidth: 160 }}>
<b>{x.r.code}</b> · {x.r.garments} garment{x.r.garments === 1 ? "" : "s"} across {x.r.lineCount} line{x.r.lineCount === 1 ? "" : "s"}{x.r.reason ? ` · ${x.r.reason}` : ""} · {statusText(x.r).label}
</span>
{/* Their own request, gone to them to decide. A manager may do that now, and without
this the row reads exactly like one their manager approved — the status label
names nobody, so "approved" beside a request they approved themselves is the one
thing an audit would want to see and the one thing it could not. */}
{x.r.managerId === st.id && <span className="tag tag-accent" title={`${st.first} ${st.last} is the manager on this request as well as the person it is for`}>{selfTag(x.r.status)}</span>}
<button className="btn btn-ghost" style={{ minHeight: 26, padding: "2px 8px" }} aria-label={`Print the order form again for request ${x.r.code}`} onClick={() => openForm(`request=${encodeURIComponent(x.r.id)}`)}>Print the form</button>
</div>
))}
{/* A history that stops at a ceiling with nothing said reads as a complete history. It is
a high ceiling and most records will never reach it, which is exactly why the one that
does has to say so. */}
{formsCapped && <div style={{ fontSize: 12.5, fontWeight: 600, color: "var(--color-neutral-800)", padding: "var(--space-2) 0" }}>{formsCapped}</div>}
{reqForms === null && <div style={{ fontSize: 12.5, color: "var(--color-neutral-700)", padding: "var(--space-2) 0" }}>Looking for the requests raised for them</div>}
{!formsErr && reqForms !== null && forms.length === 0 && <div style={{ fontSize: 13, color: "var(--color-neutral-700)", padding: "var(--space-3) 0", borderBottom: "1px solid var(--color-divider)" }}>No order form has been printed or recorded for them yet.</div>}
</Panel>
<Panel title="Notes">
<textarea className="input" rows={4} style={{ width: "100%" }} aria-label={`Notes about ${st.first} ${st.last}`} value={notes ?? st.notes} onChange={(e) => saveNotes(e.target.value)} placeholder="General notes — preferences, fit issues, agreements…" disabled={!isAdmin} />
</Panel>
<Panel title="Waiting for pickup">
{pickups.length === 0 && <Empty pad={3}>Nothing waiting for collection.</Empty>}
{pickups.map((p) => (
<div key={p.id} style={{ padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13 }}>
<div style={{ fontWeight: 600 }}>{p.lines.map((l) => `${label(byId[l.itemId])} ${l.size} ×${l.qty}`).join(", ")}</div>
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: 2 }}>Received {fmtDate(p.received)} · {p.contacted ? "Contacted" : "Not yet contacted"} · <Link href={`/app/orders/${p.orderId}`}>{p.orderCode}</Link></div>
</div>
))}
</Panel>
<Panel title="Staff app">
<label style={{ display: "flex", gap: "var(--space-2)", alignItems: "flex-start", fontSize: 13, lineHeight: 1.55 }}>
<input type="checkbox" checked={st.wardDesk} disabled={!isAdmin}
onChange={(e) => act("staff.patch", { id: st.id, wardDesk: e.target.checked })}
style={{ marginTop: 2, width: 18, height: 18, flex: "none" }} />
<span>
<b>On the ward desk</b> signs in the staff app for the bags the round drops at {st.dept || "their ward"}{st.dept ? "" : " (no ward recorded yet)"}.
</span>
</label>
{/* Keyed on the record, like the list of reports below it. The one-time code lives inside
here and is shown once; walking from this record to another through that list would
otherwise leave it up under the new name, beside a Print the slip button that puts
that name on somebody else's code. */}
<SelfService key={st.id} st={st} act={act} mutate={mutate} isAdmin={isAdmin} facility={s.settings.facility} tz={s.settings.timezone} />
</Panel>
{/* The other end of the same arrow as "Manager" in the Managers approval section above.
Keyed on the record so walking from one person to the next empties the search box
instead of carrying the last person's half-typed name onto it. */}
<Panel title="Whose requests they approve" aside={reports.length ? `${reports.length} ${reports.length === 1 ? "person" : "people"}` : undefined}>
<Reports key={st.id} subject={st} reports={reports} people={s.staff} isAdmin={isAdmin} act={act} />
</Panel>
</div>
<div>
<Panel title="Alterations">
<div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap" }}>
<input className="input" style={{ flex: 1, minWidth: 160 }} aria-label="Garment to be altered" placeholder="Garment — e.g. Scrub pants (M)" value={alt.garment} onChange={(e) => setAlt({ ...alt, garment: e.target.value })} />
<input className="input" style={{ flex: 1.4, minWidth: 200 }} aria-label="What the alteration is" placeholder="Alteration — e.g. hem 4cm, take in waist" value={alt.desc} onChange={(e) => setAlt({ ...alt, desc: e.target.value })} />
<button className="btn btn-secondary" disabled={!alt.garment.trim()} onClick={async () => { if (await act("alteration.add", { staffId: st.id, ...alt })) setAlt({ garment: "", desc: "" }); }}>Log alteration</button>
</div>
{alterations.length === 0 && <div style={{ fontSize: 13, color: "var(--color-neutral-700)", padding: "var(--space-3) 0", borderBottom: "1px solid var(--color-divider)" }}>No alterations recorded.</div>}
{alterations.map((a) => (
<div key={a.id} style={{ display: "flex", alignItems: "center", gap: "var(--space-3)", padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13, flexWrap: "wrap" }}>
<span style={{ fontSize: 12, color: "var(--color-neutral-700)", flex: "none" }}>{fmtDate(a.date)}</span>
<span style={{ fontWeight: 600 }}>{a.garment}</span>
<span style={{ color: "var(--color-neutral-800)", flex: 1 }}>{a.desc || "—"}</span>
<span className={a.status === "Returned to staff" ? "tag tag-neutral" : a.status === "At tailor" ? "tag tag-accent" : "tag tag-outline"}>{a.status}</span>
{a.status !== "Returned to staff" && <button className="btn btn-ghost" onClick={() => act("alteration.advance", { id: a.id })}>{a.status === "Requested" ? "Send to tailor" : "Mark returned"}</button>}
<button className="btn btn-ghost btn-icon" title="Remove entry" aria-label={`Remove the alteration logged for ${a.garment}`} onClick={() => act("alteration.remove", { id: a.id })}>×</button>
</div>
))}
</Panel>
<Panel title="Uniform hand-ins" aside={<button className="btn btn-secondary" style={{ minHeight: 28, padding: "2px 10px" }} onClick={() => setHandin(true)}>Record hand-in</button>}>
<LiveRegion msg={hiMsg} style={{ fontSize: 13, fontWeight: 600 }} />
{handins.length === 0 && <div style={{ fontSize: 13, color: "var(--color-neutral-700)", padding: "var(--space-3) 0", borderBottom: "1px solid var(--color-divider)" }}>No hand-ins on file.</div>}
{handins.map((h) => { const good = h.lines.filter((l) => l.cond === "Good").reduce((t, l) => t + l.qty, 0), rag = h.lines.filter((l) => l.cond === "Rag").reduce((t, l) => t + l.qty, 0); return (
<div key={h.id} style={{ display: "flex", alignItems: "center", gap: "var(--space-3)", padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13, flexWrap: "wrap" }}>
<span style={{ fontSize: 12, color: "var(--color-neutral-700)", flex: "none" }}>{fmtDate(h.date)}</span>
<span style={{ flex: 1 }}>{good ? `${good} to pre-loved pool` : ""}{good && rag ? " · " : ""}{rag ? `${rag} rag` : ""} · received by {h.by}</span>
{h.credit && <span className="tag tag-accent">Credited</span>}
<button className="btn btn-ghost" style={{ minHeight: 26, padding: "2px 8px" }} onClick={() => printHandInReceipt(s, st, h, byId)}>Receipt</button>
</div>
); })}
</Panel>
<Panel title="Issue history" aside={issues.length ? `${issues.length} ${issues.length === 1 ? "line" : "lines"}` : undefined}>
{issues.length === 0 && <Empty pad={3}>Nothing issued yet.</Empty>}
{issues.length > 0 && (
<div className="table-wrap"><table className="table">
<thead><tr>{th("Date")}{th("Item")}{th("Size")}{th("Qty", true)}{th("Value", true)}{th("Status")}<th></th></tr></thead>
<tbody>{issues.map((i) => { const it = byId[i.itemId]; return <tr key={i.id}><td style={{ fontSize: 12 }}>{fmtDate(i.date)}</td><td>{label(it)}</td><td>{it?.sizes[i.si]}</td><td style={{ textAlign: "right" }}>{i.qty}</td><td style={{ textAlign: "right" }}>{money(i.qty * issueCost(i, byId))}</td><td><span className={i.returned ? "tag tag-outline" : "tag tag-neutral"}>{i.returned ? i.returned.cond.replace("Returned - ", "Returned ") : i.preloved ? "Pre-loved" : i.direct ? "Collected" : "Issued"}</span>{i.handedIn && <span className="tag tag-outline" style={{ marginLeft: 6 }} title={`Handed in ${fmtDate(i.handedIn)}`}>Handed in</span>}{i.override && <span className="tag tag-accent" style={{ marginLeft: 6 }} title="A coordinator recorded an override: this garment took them past the sets one person holds.">Override</span>}{i.returned?.photoId && <button className="btn btn-ghost" style={{ minHeight: 22, padding: "0 6px", marginLeft: 6 }} onClick={() => viewPhoto(i.returned!.photoId!)}>Photo</button>}</td><td style={{ textAlign: "right" }}>{!i.returned && !i.handedIn && <button className="btn btn-ghost" style={{ minHeight: 24, padding: "2px 8px" }} onClick={() => setRet(i)}>Return</button>}</td></tr>; })}</tbody>
</table></div>
)}
</Panel>
<Panel title="Orders for this staff member">
{orders.length === 0 && <Empty pad={3}>No orders placed for them.</Empty>}
{orders.map((o) => (
<div key={o.id} style={{ display: "flex", alignItems: "center", gap: "var(--space-3)", padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13, flexWrap: "wrap" }}>
<Link href={`/app/orders/${o.id}`} className="link-name" style={{ fontWeight: 700 }}>{o.code}</Link>
<span style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>{fmtDate(o.date)}</span>
<span style={{ flex: 1 }}>{o.lines.reduce((t, l) => t + l.qty, 0)} items · {o.supplier}</span>
<span className={statusTag(o.status)}>{o.status}</span>
</div>
))}
</Panel>
</div>
</div>
{ret && <ReturnDialog issue={ret} onClose={() => setRet(null)} />}
{handin && <HandInDialog staff={st} onClose={() => setHandin(false)} onDone={setHiMsg} />}
</section>
);
}
/* The person's manager — who approves their requests in the staff app and whose name a signed order
* form is recorded under. One box: the owner asked for exactly that, after a Manager dropdown and an
* "Approved by" search sat one above the other, the first never saving what the second chose.
*
* Found on the register rather than picked off a list of hundreds, and saved the moment a name is
* chosen — nothing waits on a button, so leaving the record loses nothing.
*
* The person themselves is on the list. Anyone may be their own manager (the owner's decision — on a
* facility where one person is the whole register there is nobody else to name), and the box says
* Self-approved the moment they are, as every approval they then make for themselves is marked.
*/
function ManagerBox({ people, subject, isAdmin, act }: {
people: StaffRec[]; subject: StaffRec; isAdmin: boolean;
act: (op: string, payload: unknown) => Promise<boolean>;
}) {
const [q, setQ] = useState("");
const [changing, setChanging] = useState(false);
const [busy, setBusy] = useState(false);
const results = useRef<(HTMLButtonElement | null)[]>([]);
const needle = q.trim().toLowerCase();
const mgr = subject.managerId ? people.find((x) => x.id === subject.managerId) : undefined;
/* Nothing is listed until something is typed. A linen room's register runs to hundreds of names,
and eight arbitrary ones under an empty box are noise somebody has to read past every time. */
const matches = useMemo(() => {
if (!needle) return [];
return people
.filter((x) => !x.inactive && (`${x.first} ${x.last}`.toLowerCase().includes(needle) || x.num.toLowerCase().includes(needle)))
.slice(0, 8);
}, [people, needle]);
/* Down and Up walk the results, and Down out of the search box steps into the first one, so the
whole thing is reachable without a mouse. Moving only moves: unlike the staff app's radio
group, choosing here collapses the list, so a first Down that also chose would shut the list
under the coordinator's hands before they had seen the second name. */
function onListKey(e: React.KeyboardEvent) {
const fwd = e.key === "ArrowDown", back = e.key === "ArrowUp";
if ((!fwd && !back) || matches.length === 0) return;
e.preventDefault();
const at = results.current.findIndex((el) => el === document.activeElement);
const next = at < 0 ? 0 : at + (fwd ? 1 : -1);
results.current[Math.max(0, Math.min(matches.length - 1, next))]?.focus();
}
/* Saved on the pick, and the search closed only once it has gone through: a refusal lands in the
page's error line with the box still open on what was typed. "" un-sets the manager. */
async function save(managerId: string) {
setBusy(true);
const ok = await act("staff.patch", { id: subject.id, managerId });
setBusy(false);
if (ok) { setQ(""); setChanging(false); }
}
const LABEL: React.CSSProperties = { fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-neutral-700)" };
if (mgr && !changing) {
const self = mgr.id === subject.id;
const name = `${mgr.first} ${mgr.last}`.trim();
return (
<div>
<div style={LABEL}>Manager</div>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", flexWrap: "wrap", marginTop: 4 }}>
<b style={{ fontSize: 14 }}>{name}{self ? " (themselves)" : ""}</b>
{self && <span className="tag tag-accent" title={`${subject.first} ${subject.last} approves their own requests and forms`}>Self-approved</span>}
{/* Somebody since taken off the register is still who the record names, so the box says so
rather than passing them off as a manager who can approve anything today. */}
{mgr.inactive && <span className="tag tag-outline">Inactive</span>}
{isAdmin && (
<>
<button className="btn btn-ghost" style={{ minHeight: 26, padding: "2px 8px" }} disabled={busy} aria-label={`Change ${subject.first}s manager — ${name} is set`} onClick={() => { setQ(""); setChanging(true); }}>Change</button>
<button className="btn btn-ghost" style={{ minHeight: 26, padding: "2px 8px" }} disabled={busy} aria-label={`Remove ${name} as ${subject.first}s manager`}
onClick={() => { if (confirm(`Remove ${name} as ${subject.first}'s manager? ${subject.first} can't raise requests until one is set.`)) save(""); }}>Remove</button>
</>
)}
</div>
</div>
);
}
if (!isAdmin) {
return (
<div>
<div style={LABEL}>Manager</div>
<div style={{ fontSize: 13, color: "var(--color-neutral-700)", marginTop: 4 }}>None set</div>
</div>
);
}
return (
<div>
<Field label="Manager" hint={changing ? undefined : "Approves their requests and signed order forms."}>
{(c) => (
<input {...c} className="input" value={q} autoComplete="off" placeholder="Name or staff number"
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => {
if (e.key === "ArrowDown" && matches.length) { e.preventDefault(); results.current[0]?.focus(); }
else if (e.key === "Escape" && changing) { e.preventDefault(); setQ(""); setChanging(false); }
}} />
)}
</Field>
{changing && (
<button className="btn btn-ghost" style={{ minHeight: 26, padding: "2px 8px", marginTop: "var(--space-2)" }} onClick={() => { setQ(""); setChanging(false); }}>Cancel</button>
)}
{needle !== "" && (
<div role="group" aria-label="Matching staff" onKeyDown={onListKey} style={{ display: "grid", gap: 2, marginTop: "var(--space-2)" }}>
{matches.map((x, i) => {
const self = x.id === subject.id;
return (
<button key={x.id} type="button" ref={(el) => { results.current[i] = el; }} disabled={busy}
aria-label={`Set ${x.first} ${x.last}${self ? " (themselves)" : ""}${x.num ? `, staff number ${x.num}` : ""} as ${subject.first}s manager`}
onClick={() => save(x.id)}
style={{ display: "block", width: "100%", textAlign: "left", font: "inherit", fontSize: 13, minHeight: 36, padding: "6px 10px", background: "transparent", border: "1px solid var(--color-divider)", cursor: busy ? "default" : "pointer" }}>
<b>{x.first} {x.last}</b>{self ? " (themselves)" : ""}
<span style={{ color: "var(--color-neutral-700)", marginLeft: 8 }}>{[x.num, x.dept].filter(Boolean).join(" · ")}</span>
</button>
);
})}
{matches.length === 0 && <div style={{ fontSize: 12.5, color: "var(--color-neutral-700)" }}>Nobody on the register matches that.</div>}
</div>
)}
</div>
);
}
/* Who this person approves for — the same arrow as the Manager box, drawn from the manager's end.
*
* Only the wearer's record names an approver; nothing anywhere names the reports. So making Jane a
* manager meant opening all fourteen of her nurses in turn and setting her on each, while her own
* record showed a count and nothing else — and showed nothing at all when she had none, which is
* exactly the moment somebody is trying to make her one. Somebody who is their own manager is on
* their own list, because they do approve their own requests.
*
* Assigning somebody here rewrites THEIR record, not this one, and nobody tells them. That is said
* in full — the person named, and the manager they are being taken off — before the write rather
* than after it. A button that quietly changes a record you are not looking at is worse than the
* fourteen walks it saves.
*/
function Reports({ subject, reports, people, isAdmin, act }: {
subject: StaffRec; reports: StaffRec[]; people: StaffRec[]; isAdmin: boolean;
act: (op: string, payload: unknown) => Promise<boolean>;
}) {
const [q, setQ] = useState("");
const [pick, setPick] = useState<StaffRec | null>(null);
const needle = q.trim().toLowerCase();
/* The same rule as the manager search further up: nothing is listed until something is typed,
because a register of hundreds under an empty box is noise somebody reads past every time.
Anybody already on the list is left out of the matches — picking them again would write a change
that changes nothing, and the register would look as though it had refused. */
const matches = useMemo(() => {
if (!needle) return [];
return people
.filter((x) => !x.inactive && x.id !== subject.id && x.managerId !== subject.id
&& (`${x.first} ${x.last}`.toLowerCase().includes(needle) || x.num.toLowerCase().includes(needle)))
.slice(0, 8);
}, [people, subject.id, needle]);
// Who the person being moved goes to at the moment, so the card below can name what is being
// undone rather than only what is being done.
const from = pick ? people.find((x) => x.id === pick.managerId) : undefined;
return (
<>
{reports.length === 0 && (
<div style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-neutral-700)" }}>Nobody reports to {subject.first} yet.</div>
)}
{reports.map((x) => (
<div key={x.id} style={{ display: "flex", alignItems: "center", gap: "var(--space-3)", padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13, flexWrap: "wrap" }}>
<Link href={`/app/staff/${x.id}`} className="link-name" style={{ fontWeight: 600 }}>{x.first} {x.last}</Link>{x.id === subject.id ? " (themselves)" : ""}
<span style={{ flex: 1, fontSize: 12, color: "var(--color-neutral-700)" }}>{[x.num, x.dept].filter(Boolean).join(" · ") || "—"}</span>
{/* Taking somebody off this list leaves them with nobody, which stops them raising anything
at all — so it says that, and says it about the person it will happen to. The way to
hand them to a different manager is on their own record, where the choice is a list of
everybody rather than a single name. */}
{isAdmin && (
<button className="btn btn-ghost" style={{ minHeight: 26, padding: "2px 8px" }}
aria-label={`Take ${x.first} ${x.last} off ${subject.first}s list — they will have no approver`}
onClick={() => { if (confirm(`Take ${x.first} ${x.last} off ${subject.first}'s list? They'll have no manager, so they can't raise requests until one is set.`)) act("staff.patch", { id: x.id, managerId: "" }); }}>
Remove from list
</button>
)}
</div>
))}
{isAdmin && (subject.inactive ? (
<div style={{ fontSize: 12.5, color: "var(--color-neutral-700)", lineHeight: 1.55, marginTop: "var(--space-3)" }}>
Reactivate {subject.first} to give them reports.
</div>
) : pick ? (
/* The whole of what the button does, before it does it. Two records are involved and only one
of them is on this screen, so the one that is about to change is the one named first. */
<div style={{ border: "2px solid var(--color-text)", padding: "var(--space-3)", marginTop: "var(--space-3)", fontSize: 13, lineHeight: 1.6 }}>
Send <b>{pick.first} {pick.last}</b>s requests to {subject.first} {subject.last}{from ? <> instead of {from.first} {from.last}</> : null}?
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", flexWrap: "wrap" }}>
<button className="btn btn-secondary" onClick={async () => { if (await act("staff.patch", { id: pick.id, managerId: subject.id })) { setPick(null); setQ(""); } }}>Change {pick.first}s record</button>
<button className="btn btn-ghost" onClick={() => setPick(null)}>Cancel</button>
</div>
</div>
) : (
<div style={{ marginTop: "var(--space-3)" }}>
<Field label="Add somebody who reports to them">
{(c) => <input {...c} className="input" value={q} autoComplete="off" placeholder="Name or staff number" onChange={(e) => setQ(e.target.value)} />}
</Field>
{needle !== "" && (
<div role="group" aria-label="Matching staff" style={{ display: "grid", gap: 2, marginTop: "var(--space-2)" }}>
{matches.map((x) => (
<button key={x.id} type="button" onClick={() => setPick(x)}
aria-label={`Send ${x.first} ${x.last}s requests to ${subject.first} ${subject.last}`}
style={{ display: "block", width: "100%", textAlign: "left", font: "inherit", fontSize: 13, minHeight: 36, padding: "6px 10px", background: "transparent", border: "1px solid var(--color-divider)", cursor: "pointer" }}>
<b>{x.first} {x.last}</b>
<span style={{ color: "var(--color-neutral-700)", marginLeft: 8 }}>{[x.num, x.dept].filter(Boolean).join(" · ")}</span>
</button>
))}
{matches.length === 0 && <div style={{ fontSize: 12.5, color: "var(--color-neutral-700)" }}>Nobody else on the register matches that.</div>}
</div>
)}
</div>
))}
</>
);
}
/* Handing someone the key to their own record.
*
* The code is shown here once, in the response to the op that made it, and never again — it is a
* credential, and the snapshot every coordinator's browser holds carries only the fact that one is
* outstanding. Losing it costs a reprint, which is the right price. */
function SelfService({ st, act, mutate, isAdmin, facility, tz }: {
st: StaffRec;
act: (op: string, payload: unknown) => Promise<boolean>;
mutate: <T = unknown>(op: string, payload?: unknown) => Promise<{ ok: true; result: T } | { ok: false; error: string }>;
isAdmin: boolean;
facility: string;
tz: string;
}) {
const [code, setCode] = useState<string | null>(null);
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
/* Whether the outstanding slip would still be accepted, asked of slipLive() — the same test the
* activation route applies — and how old it is in the facility's calendar days, for the sentence
* that says so.
*
* "A code is outstanding" on its own reads the same for a slip printed this morning and one that
* died three weeks ago, and the coordinator only finds out which when the nurse walks back to the
* counter having been refused. An outstanding code with no print date is not a live slip either:
* activation turns an undated slip away, so this screen has to say so rather than imply it is fine. */
const today = facilityToday(tz);
const live = slipLive(st.selfCodeAt, today, tz);
const printed = facilityDate(st.selfCodeAt ?? "", tz);
const age = printed ? daysBetween(printed, today) : null;
const left = age === null ? null : SLIP_DAYS - age;
const when = age === 0 ? "today" : age === 1 ? "yesterday" : `${age} days ago`;
if (code) {
return (
<div style={{ border: "2px solid var(--color-text)", padding: "var(--space-4)", marginTop: "var(--space-3)" }}>
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-700)" }}>Code for {st.first}</div>
<div style={{ fontFamily: "ui-monospace, Menlo, Consolas, monospace", fontSize: 26, fontWeight: 800, letterSpacing: "0.06em", margin: "6px 0 0" }}>{code}</div>
<p style={{ fontSize: 12.5, lineHeight: 1.6, color: "var(--color-neutral-700)", margin: "8px 0 0" }}>
Shown once print the slip or write it down before closing this.
</p>
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", flexWrap: "wrap" }}>
<button className="btn btn-primary" onClick={() => printAccessSlip({ settings: { facility } }, st, code)}>Print the slip</button>
<button className="btn btn-secondary" onClick={() => navigator.clipboard?.writeText(code).catch(() => {})}>Copy</button>
<button className="btn btn-ghost" onClick={() => setCode(null)}>Done</button>
</div>
</div>
);
}
return (
<>
<div style={{ fontSize: 13, color: "var(--color-neutral-700)", lineHeight: 1.6, marginTop: "var(--space-3)" }}>
{st.selfEmail
? <>Signed up as <b>{st.selfEmail}</b>.</>
: st.selfCode
? age === null
? <>A code is outstanding but <b>won&apos;t work</b> it has no print date. Make a new one.</>
: live
? <>A code is outstanding printed {when}, not used yet. It expires {left === 1 ? "tomorrow" : `in ${left} days`}.</>
: <>A code is outstanding but <b>has expired</b> printed {when}. Make a new one.</>
: <>No staff-app login yet generate a code to give {st.first} one.</>}
</div>
<ErrorLine msg={err} />
{isAdmin && (
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", flexWrap: "wrap" }}>
{!st.selfEmail && (
<button className="btn btn-secondary" disabled={busy} onClick={async () => {
setBusy(true); setErr("");
const r = await mutate<{ code: string }>("staff.selfCode", { id: st.id });
setBusy(false);
if (!r.ok) { setErr(r.error); return; }
setCode(r.result.code);
}}>{busy ? "Generating…" : st.selfCode ? "New code" : "Generate a code"}</button>
)}
{st.selfCode && !st.selfEmail && (
<button className="btn btn-ghost" onClick={() => act("staff.selfClear", { id: st.id })}>Cancel the code</button>
)}
{st.selfEmail && (
<button className="btn btn-ghost" onClick={() => {
if (confirm(`Remove ${st.first}'s access? Theyll be signed out and will need a new code to get back in.`)) act("staff.selfUnlink", { id: st.id });
}}>Remove access</button>
)}
</div>
)}
</>
);
}