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).
1003 lines
76 KiB
TypeScript
1003 lines
76 KiB
TypeScript
"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 aren’t listed.` : "");
|
||
} catch {
|
||
if (!live) return;
|
||
setReqForms([]);
|
||
setFormsCapped("");
|
||
setFormsErr("The requests raised for them couldn’t 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 doesn’t 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'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="Manager’s 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 Manager’s 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'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? They’ll be signed out and will need a new code to get back in.`)) act("staff.selfUnlink", { id: st.id });
|
||
}}>Remove access</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|