Files
ThreadCount 0910bc32c1 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 38e16eb on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-16 07:57:54 +10:00

353 lines
17 KiB
TypeScript

"use client";
/* 2C — Raise for someone you manage.
*
* A manager standing next to somebody who will not type it in themselves: they pick the person,
* build the list, and the record says both names. The ward desk once had a screen of its own for
* the same job, on the grounds that half a ward would never install anything; it is gone, because
* the manager is already the person that ward would have asked.
*
* The rule that shapes it: **nobody approves their own raise.** The approver is the subject's
* manager — which here is the person typing — so the server sends it a level up, and this screen
* says so by name before the button is pressed. A manager who could approve a request they typed
* themselves would be no approval at all.
*/
import { useMemo, useRef, useState } from "react";
import { MBar, MBody, MError, MRule, MTop, inputStyle } from "@/components/m";
import {
ACCENT_300, DarkCard, DraftLineList, EdgeRow, GarmentPicker, GROUND, INK, Kicker, N500, N600, N700,
NumberedField, OptionList, type DraftLine,
} from "@/components/staffui";
import { useStaff } from "@/lib/staffclient";
import { REQUEST_REASONS, statusText } from "@/lib/staffreq";
import type { ReqRow } from "@/lib/staffdata";
type Person = {
id: string; name: string; num: string; group: string; hasApp: boolean;
recordedTop: string; recordedPants: string;
held: Record<string, number>; lastSizes: Record<string, string>;
};
type Size = { size: string; si: number; word: string; countedOn: string };
type Item = { id: string; item: string; type: string; gender: string; sizes: Size[]; recorded: string; isTop: boolean; isPant: boolean };
export default function DeskScreen({ people, items, raised, maxLines, maxQty }: {
/** The people who report to whoever is raising, which is exactly the set the server accepts. */
people: Person[]; items: Item[];
/** What they have raised for other people and not yet seen the end of. */
raised: ReqRow[];
maxLines: number; maxQty: number;
}) {
const { mutate, busy } = useStaff();
const [q, setQ] = useState("");
const [personId, setPersonId] = useState<string | null>(null);
const [lines, setLines] = useState<DraftLine[]>([]);
const [adding, setAdding] = useState(false);
const [reason, setReason] = useState<string | null>(null);
const [note, setNote] = useState("");
const [err, setErr] = useState("");
const [sent, setSent] = useState<{ id: string; code: string; manager: string; escalated: boolean } | null>(null);
const person = people.find((p) => p.id === personId) || null;
const garments = lines.reduce((n, l) => n + l.qty, 0);
const full = lines.length >= maxLines;
const picking = adding || lines.length === 0;
const matches = useMemo(() => {
const needle = q.trim().toLowerCase();
if (!needle) return people.slice(0, 8);
return people.filter((p) => p.name.toLowerCase().includes(needle) || p.num.toLowerCase().includes(needle)).slice(0, 12);
}, [people, q]);
function pick(id: string) {
setPersonId(id);
setErr("");
setLines([]);
setAdding(false);
}
/* The result list is a radio group, so it has to answer the arrow keys.
*
* A screen reader in forms mode announces it as "radio group, 1 of 8", and a manager standing
* next to the nurse they are raising for presses Down to reach her — having been told it is a
* radio group, they have no reason to try Tab. Without this they sit on the first name and give
* up. Two halves make it work: one tab stop into the group (the roving tabIndex below), and the
* arrows moving inside it. Moving also selects, the way a radio group does everywhere else,
* which clears any draft lines exactly as clicking a different name always has. */
const radios = useRef<(HTMLButtonElement | null)[]>([]);
const activeIdx = Math.max(0, matches.findIndex((p) => p.id === personId));
function moveTo(i: number) {
const next = matches[i];
if (!next) return;
pick(next.id);
radios.current[i]?.focus();
}
function onResultKey(e: React.KeyboardEvent) {
if (!matches.length) return;
const fwd = e.key === "ArrowDown" || e.key === "ArrowRight";
const back = e.key === "ArrowUp" || e.key === "ArrowLeft";
if (!fwd && !back && e.key !== "Home" && e.key !== "End") return;
e.preventDefault(); // otherwise the arrows scroll the list out from under the focused name
if (e.key === "Home") return moveTo(0);
if (e.key === "End") return moveTo(matches.length - 1);
// Nothing chosen yet — or the search has moved on past whoever was — so the first press lands
// on the first match rather than skipping it.
const at = matches.findIndex((p) => p.id === personId);
if (at < 0) return moveTo(0);
moveTo((at + (fwd ? 1 : -1) + matches.length) % matches.length);
}
/* Their recorded size, not the clerk's guess. Tops and trousers carry one on the register; for
* everything else — a fleece, a vest, a dress — the size of the last one they were issued is all
* the record knows, and it is a far better opening bid than an empty grid. */
function sizeFor(it: Item): string {
if (!person) return "";
if (it.isPant) return person.recordedPants;
if (it.isTop) return person.recordedTop;
return person.lastSizes[it.id] || "";
}
function add(l: { itemId: string; si: number; item: string; size: string; qty: number }) {
setErr("");
setLines((cur) => {
// The server sums duplicate lines before it writes them, so two identical rows on the screen
// would be showing something that cannot be saved.
const at = cur.findIndex((x) => x.itemId === l.itemId && x.si === l.si);
if (at >= 0) {
const next = [...cur];
next[at] = { ...next[at], qty: Math.min(maxQty, next[at].qty + l.qty) };
return next;
}
return [...cur, { ...l, key: `${l.itemId}:${l.si}:${cur.length}` }];
});
setAdding(false);
}
const ready = !!person && lines.length > 0;
/* Where it actually went.
*
* A manager raising for their own report is approving nothing: the server sends it up a level,
* or leaves it for the linen room to address when there is nobody above them. Either way the
* person who typed it needs to be told, by name, rather than dropped on an order screen that
* says "awaiting approval" and leaves them to work out whose. */
if (sent) {
return (
<>
<MTop title="Raised" />
<MRule />
<MBody>
<div style={{ padding: 16 }}>
<DarkCard
kicker={sent.code}
title={sent.manager ? `With ${sent.manager}` : "Nobody approves this yet"}
meta={sent.manager
? (sent.escalated
? "You approve their requests, so this went up a level — nobody approves their own raise."
: "They have been told, and it stays on your list until it is done.")
: "There is nobody above you on the register, so the linen room will address it to an approver."}
>
<div style={{ borderTop: "1px solid #4a4746", marginTop: 16, paddingTop: 14, fontSize: 13, lineHeight: 1.55, color: N500 }}>
You&rsquo;ll see the outcome here and under Raised in your orders.
</div>
</DarkCard>
</div>
</MBody>
<MBar label="See the order" glyph="arrow" href={`/my/orders/${sent.id}`} />
</>
);
}
return (
<>
<MTop
title="Raise for your team"
back
right={<span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>Manager</span>}
/>
<MRule />
<MBody>
<NumberedField n={1} label="Who is it for" first>
{/* The NumberedField heading names the step, not this box, so the box says what it is
itself — a placeholder disappears the moment anyone types into it. */}
<input
value={q} onChange={(e) => { setQ(e.target.value); setErr(""); }}
aria-label="Search by name or staff number"
placeholder="Name or staff number" autoComplete="off"
style={{ ...inputStyle, width: "100%" }}
/>
<div role="radiogroup" aria-label="Search results" onKeyDown={onResultKey} style={{ display: "grid", gap: 2, marginTop: 12 }}>
{matches.map((p, i) => {
const on = p.id === personId;
return (
<button key={p.id} role="radio" aria-checked={on}
ref={(el) => { radios.current[i] = el; }}
// One tab stop for the whole group: Tab reaches the chosen name (or the first
// one), and the arrows move between them from there.
tabIndex={i === activeIdx ? 0 : -1}
onClick={() => pick(p.id)}
style={{
textAlign: "left", padding: "12px 14px", border: 0, borderRadius: 0, font: "inherit",
background: on ? INK : "#fff", color: on ? GROUND : INK, cursor: "pointer", minHeight: 48,
}}>
<div style={{ fontSize: 16, fontWeight: 800 }}>{p.name}</div>
<div style={{ fontSize: 12.5, marginTop: 3, opacity: 0.85 }}>{[p.num, p.group].filter(Boolean).join(" · ")}</div>
{on && !p.hasApp && (
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300, marginTop: 6 }}>
No app you&rsquo;ll have to pass the outcome on
</div>
)}
</button>
);
})}
{matches.length === 0 && (
<p style={{ fontSize: 14, color: N600, lineHeight: 1.6, margin: 0 }}>Nobody on your team matches that.</p>
)}
</div>
</NumberedField>
{person && (
<NumberedField n={2} label={`What ${person.name.split(" ")[0]} needs`}>
{lines.length > 0 && (
<div style={{ marginBottom: picking ? 14 : 0 }}>
<DraftLineList
lines={lines}
maxQty={maxQty}
onQty={(k, q2) => setLines((cur) => cur.map((l) => (l.key === k ? { ...l, qty: q2 } : l)))}
onRemove={(k) => setLines((cur) => cur.filter((l) => l.key !== k))}
/>
</div>
)}
{picking ? (
<GarmentPicker
items={items}
maxQty={maxQty}
addLabel={lines.length ? "Add it" : "Add to the request"}
onCancel={lines.length ? () => setAdding(false) : undefined}
defaultSi={(it) => {
const want = sizeFor(it);
return it.sizes.find((s) => String(s.size) === String(want))?.si ?? null;
}}
note={(it) => {
const rec = sizeFor(it);
const holds = person.held[it.id] || 0;
return [
rec ? `Recorded size ${rec}` : "No size on record for this one",
holds ? `holds ${holds}` : "",
].filter(Boolean).join(" · ");
}}
onAdd={add}
/>
) : full ? (
<p style={{ fontSize: 13, color: N600, lineHeight: 1.55, margin: "12px 0 0" }}>
That is {maxLines} lines as much as one request carries. Send this one and raise
another for anything else.
</p>
) : (
<button
onClick={() => { setAdding(true); setErr(""); }}
style={{
width: "100%", minHeight: 52, marginTop: 2, border: `2px solid ${INK}`, borderRadius: 0,
background: "transparent", color: INK, font: "inherit", fontWeight: 800, fontSize: 13,
letterSpacing: "0.06em", textTransform: "uppercase", textAlign: "left", padding: "0 16px", cursor: "pointer",
}}
>Add another garment</button>
)}
{lines.length > 0 && !picking && (
<div style={{ marginTop: 14 }}>
<OptionList
label="Why"
columns={2}
value={reason}
onPick={setReason}
options={REQUEST_REASONS.map((r) => ({ key: r, label: r }))}
/>
<textarea
value={note} onChange={(e) => setNote(e.target.value)} rows={2}
aria-label="Anything the linen room should know (optional)"
placeholder="Anything the linen room should know (optional)"
style={{ width: "100%", minHeight: 68, marginTop: 14, padding: 12, border: "2px solid var(--color-divider)", borderRadius: 0, font: "inherit", fontSize: 15, resize: "none", background: "#fff", color: "var(--color-text)" }}
/>
</div>
)}
</NumberedField>
)}
{person && lines.length > 0 && (
<NumberedField n={3} label="Approval and collection">
<div style={{ background: "#fff", borderLeft: `6px solid ${INK}`, padding: "14px 16px" }}>
<Kicker>Goes above you, not to you</Kicker>
<div style={{ fontSize: 17, fontWeight: 800, marginTop: 6 }}>Your own manager</div>
<p style={{ fontSize: 13.5, lineHeight: 1.55, color: N700, margin: "8px 0 0" }}>
You approve {person.name.split(" ")[0]}&rsquo;s requests, so this one goes up a
level nobody approves their own raise. If nobody is above you on the register,
the linen room addresses it.
</p>
</div>
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, margin: "12px 0 0" }}>
Raised by you, recorded against {person.name.split(" ")[0]}. Both names sit on the
order, and {garments === 1 ? "the garment goes" : `all ${garments} garments go`} in one bag
with one collection code.
</p>
</NumberedField>
)}
{/* What they have already raised for other people. Without this the manager who was told
"you'll see the outcome" had nowhere to see it: every list in this app starts from the
wearer, and a request raised for somebody else belongs to none of them. */}
{raised.length > 0 && (
<>
<div style={{ padding: "18px 16px 8px", borderTop: "2px solid " + INK, borderBottom: "2px solid " + INK, background: GROUND, marginTop: 18 }}>
<Kicker>Raised by you · still open</Kicker>
</div>
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
{raised.map((r) => {
const st = statusText(r, { mine: false, first: r.subjectName?.split(" ")[0] });
return (
<EdgeRow key={r.id} tone={st.ink === "attention" ? "accent" : "divider"} href={`/my/orders/${r.id}`}>
<div style={{ display: "flex", gap: 12, alignItems: "baseline" }}>
<span style={{ flex: 1, fontSize: 16, fontWeight: 800 }}>{r.subjectName}</span>
<span style={{ fontSize: 12, color: N600 }}>{r.code}</span>
</div>
<div style={{ fontSize: 14.5, fontWeight: 600, marginTop: 5, lineHeight: 1.35 }}>{r.summary}</div>
<div style={{ fontSize: 12.5, color: N600, marginTop: 4 }}>
{[st.label, st.note].filter(Boolean).join(" · ")}
</div>
</EdgeRow>
);
})}
</div>
</>
)}
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ height: 12 }} />
</MBody>
<MBar
label={busy ? "Sending…" : "Send for approval"}
sub={person && lines.length ? `${garments} garment${garments === 1 ? "" : "s"} for ${person.name.split(" ")[0]}` : undefined}
disabled={!ready || busy}
onClick={async () => {
if (!person || !lines.length) return;
const r = await mutate<{ id: string; code: string; manager: string; escalated: boolean }>("request.create", {
subjectId: person.id,
lines: lines.map((l) => ({ itemId: l.itemId, si: l.si, qty: l.qty })),
reason: reason || "", note,
});
if (!r.ok) { setErr(r.error); return; }
// A raise that went somewhere other than the obvious place — up a level, or to nobody at
// all — is worth a screen of its own. Anything ordinary goes straight to the order, which
// is where the person who typed it will come looking for it.
if (r.result.escalated || !r.result.manager) {
setSent({ id: r.result.id, code: r.result.code, manager: r.result.manager, escalated: r.result.escalated });
return;
}
window.location.assign(`/my/orders/${r.result.id}`);
}}
/>
</>
);
}