"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, N500, N600, NumberedField, OptionList, type DraftLine, } from "@/components/staffui"; import Team, { Band } from "./Team"; 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.
); } /* The bar lives below the scrolling body, so the shell is handed it rather than it being drawn inside the list, where it would scroll away with the garments it is about to send. */ const sendBar = ( { 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}`); }} /> ); return (
Raise for your team
{/* The one rule that shapes this screen: a manager who could approve what they typed themselves would be no approval at all. */}

Goes above you, not to you — your own manager approves it.

{/* 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 }))} />