"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; lastSizes: Record; }; 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(null); const [lines, setLines] = useState([]); const [adding, setAdding] = useState(false); const [reason, setReason] = useState(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 ( <>
You’ll see the outcome here and under Raised in your orders.
); } return ( <> Manager} /> {/* 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. */} { setQ(e.target.value); setErr(""); }} aria-label="Search by name or staff number" placeholder="Name or staff number" autoComplete="off" style={{ ...inputStyle, width: "100%" }} />
{matches.map((p, i) => { const on = p.id === personId; return ( ); })} {matches.length === 0 && (

Nobody on your team matches that.

)}
{person && ( {lines.length > 0 && (
setLines((cur) => cur.map((l) => (l.key === k ? { ...l, qty: q2 } : l)))} onRemove={(k) => setLines((cur) => cur.filter((l) => l.key !== k))} />
)} {picking ? ( 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 ? (

That is {maxLines} lines — as much as one request carries. Send this one and raise another for anything else.

) : ( )} {lines.length > 0 && !picking && (
({ key: r, label: r }))} />