"use client"; /* The request queue, as one reusable list. * * Imported by /app/requests (the full queue), the staff record's Requests tab and Today's Pick * group; the command panel reads useRequests(). `lines` is what was asked, `bag` is what is * picked: the pick, the slip and the collection code are always built from `bag`, so a garment * the ward declined never goes in somebody's hands. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useSnap } from "@/lib/client"; import { openSlip } from "@/components/dialogs"; import { Empty, ErrorLine } from "@/components/ui"; import { Panel, Tag } from "@/components/portal"; import { formatInZone, genderLabel, key, onhand, slipLive, type Ledger, type Snapshot } from "@/lib/compute"; import type { ReqLine } from "@/lib/staffdata"; import { NEEDS_STAFF, OPEN_REQUEST, statusText } from "@/lib/staffreq"; export type RequestMessage = { id: string; fromStaff: boolean; authorName: string; body: string; at: string }; export type RequestEvent = { id: string; label: string; meta: string; actorName: string; at: string }; /** One request exactly as GET /api/requests returns it (the API does not change). */ export type RequestRow = { id: string; code: string; status: string; staffId: string; staffName: string; staffNum: string; ward: string; lines: ReqLine[]; bag: ReqLine[]; summary: string; garments: number; lineCount: number; decision: string | null; reason: string; note: string; managerName: string; managerId: string | null; declineReason: string | null; route: string | null; collectCode: string | null; holdUntil: string; signerName: string | null; signerRole: string | null; signedAt: string | null; claimedAt: string | null; raisedById: string | null; raisedByName: string; createdAt: string; decidedAt: string | null; messages: RequestMessage[]; events: RequestEvent[]; }; export type DisputeRow = { id: string; body: string; staffName: string; staffNum: string; ward: string; at: string }; export type CycleRow = { id: string; dueBy: string; openedBy: string; openedAt: string; answers: number }; export type WaitingRow = { id: string; staffName: string; staffNum: string; ward: string; item: string; size: string; since: string; offeredAt: string | null }; export type DamageRow = { id: string; kind: string; note: string; photoId: string | null; staffId: string; staffName: string; staffNum: string; ward: string; item: string; size: string; requestCode: string; at: string }; export type ShortfallRow = { id: string; staffId: string; staffName: string; staffNum: string; ward: string; item: string; size: string; onRecord: number; confirmed: number; short: number; at: string }; export type RequestsPayload = { requests: RequestRow[]; requestLimit: number; moreRequests: boolean; /** Absent when fetched with ?staff= */ disputes?: DisputeRow[]; cycle?: CycleRow | null; shortfalls?: ShortfallRow[]; waiting?: WaitingRow[]; damage?: DamageRow[]; }; export const REQUEST_FILTERS = ["todo", "noapprover", "open", "all", "queries", "damage", "cycles"] as const; export type RequestFilter = (typeof REQUEST_FILTERS)[number]; export type RequestRowFilter = Extract; export const REQUEST_FILTER_LABEL: Record = { todo: "To do", noapprover: "Needs an approver", open: "Open", all: "All", queries: "Record queries", damage: "Damage", cycles: "Kit check & waitlist", }; export const isRowFilter = (f: RequestFilter): f is RequestRowFilter => f === "todo" || f === "noapprover" || f === "open" || f === "all"; /* ---------- status predicates: the only definitions screens may use ---------- */ const TODO = new Set(["accepted", "picking", "ready", "round"]); export function isTodo(r: RequestRow): boolean { return TODO.has(r.status); } /** Nobody was ever asked to approve it, so nobody on the ward can move it. */ export function isStranded(r: RequestRow): boolean { return r.status === "awaiting" && !r.managerName; } export function isOpenRequest(r: RequestRow): boolean { return OPEN_REQUEST.has(r.status as never); } export function isPickable(r: RequestRow): boolean { return r.status === "accepted"; } type Scope = { staffId?: string; ward?: string }; const inScope = (r: { staffId?: string; ward: string }, scope?: Scope) => (!scope?.staffId || r.staffId === scope.staffId) && (!scope?.ward || r.ward === scope.ward); export function requestsFor(rows: readonly RequestRow[], f: RequestRowFilter, scope?: Scope): RequestRow[] { const pick = f === "todo" ? isTodo : f === "noapprover" ? isStranded : f === "open" ? isOpenRequest : () => true; return rows.filter((r) => inScope(r, scope) && pick(r)); } /** The payload narrowed to one person or one ward. Record queries and the waitlist carry no staff * id, so a person is matched on their staff number there: pass it when you have the snapshot, * otherwise it is read off that person's own requests, damage or shortfall rows. */ export function scopePayload(p: RequestsPayload, scope?: Scope, staffNum?: string): RequestsPayload { if (!scope?.staffId && !scope?.ward) return p; const num = scope.staffId ? staffNum ?? [...p.requests, ...(p.damage ?? []), ...(p.shortfalls ?? [])].find((r) => r.staffId === scope.staffId)?.staffNum : undefined; const byNum = (r: { staffNum: string; ward: string }) => (!scope.staffId || (!!num && r.staffNum === num)) && (!scope.ward || r.ward === scope.ward); return { ...p, requests: p.requests.filter((r) => inScope(r, scope)), disputes: p.disputes?.filter(byNum), waiting: p.waiting?.filter(byNum), damage: p.damage?.filter((r) => inScope(r, scope)), shortfalls: p.shortfalls?.filter((r) => inScope(r, scope)), }; } export function requestCounts(p: RequestsPayload, scope?: Scope): Record { const q = scopePayload(p, scope); return { todo: q.requests.filter(isTodo).length, noapprover: q.requests.filter(isStranded).length, open: q.requests.filter(isOpenRequest).length, all: q.requests.length, queries: q.disputes?.length ?? 0, damage: q.damage?.length ?? 0, cycles: q.waiting?.length ?? 0, }; } /** Shelf check of a request's bag: inStock when every bag line has onhand(itemId:si) >= qty. */ export function bagStock(s: Snapshot, L: Ledger, r: RequestRow): { inStock: boolean; shortLines: ReqLine[] } { // Two lines for the same garment and size draw on the same shelf, so they are summed first. const want: Record = {}; for (const l of r.bag) want[key(l.itemId, l.si)] = (want[key(l.itemId, l.si)] || 0) + l.qty; const shortLines = r.bag.filter((l) => onhand(s, L, key(l.itemId, l.si)) < want[key(l.itemId, l.si)]); return { inStock: shortLines.length === 0, shortLines }; } const lineText = (l: ReqLine) => `${l.qty} × ${l.item}${l.gender && l.gender !== "Unisex" ? ` (${genderLabel(l.gender)})` : ""} — ${l.size}`; /** The slip payload for openSlip() (bag lines only, collection code, cut). */ export function requestSlip(s: Snapshot, r: RequestRow): Record { return { staffName: r.staffName, dept: r.ward, deliverTo: r.ward, sets: r.garments, po: r.code, code: r.collectCode || "", lines: r.bag.map(lineText).join("\n"), dateReceived: s.today, requestedBy: r.staffNum, deliveredBy: s.settings.coordinator, dateTime: s.today, }; } /* ---------- fetching: one module-level cache per key, shared by every screen ---------- */ type Entry = { at: number; data: RequestsPayload | null; error: string; loading: boolean; inflight: Promise | null }; const TTL = 60_000; const cache = new Map(); const listeners = new Map void>>(); const entryOf = (k: string): Entry => { let e = cache.get(k); if (!e) { e = { at: 0, data: null, error: "", loading: false, inflight: null }; cache.set(k, e); } return e; }; const notify = (k: string) => listeners.get(k)?.forEach((fn) => fn()); function load(k: string, force: boolean): Promise { const e = entryOf(k); if (e.inflight) return e.inflight; if (!force && e.data && Date.now() - e.at < TTL) return Promise.resolve(); e.loading = true; notify(k); e.inflight = (async () => { try { const r = await fetch(k ? `/api/requests?staff=${encodeURIComponent(k)}` : "/api/requests"); if (!r.ok) { const j = await r.json().catch(() => ({})); e.error = (j && j.error) || "Couldn’t load the requests."; } else { e.data = await r.json(); e.error = ""; e.at = Date.now(); } } catch { e.error = "Couldn’t reach the server — requests aren’t loaded."; } finally { e.loading = false; e.inflight = null; notify(k); } })(); return e.inflight; } /** Fetch hook. Unscoped = the whole queue plus disputes/cycle/waiting/damage; staffId = GET /api/requests?staff=. * Results are cached per key at module level for 60s; reload() refetches. Errors are returned, never thrown. */ export function useRequests(opts?: { staffId?: string; enabled?: boolean }): { data: RequestsPayload | null; error: string; loading: boolean; reload: () => Promise } { const k = opts?.staffId || ""; const enabled = opts?.enabled !== false; const [, bump] = useState(0); useEffect(() => { if (!enabled) return; let set = listeners.get(k); if (!set) { set = new Set(); listeners.set(k, set); } const subs = set; const fn = () => bump((n) => n + 1); subs.add(fn); void load(k, false); return () => { subs.delete(fn); }; }, [k, enabled]); const reload = useCallback(() => load(k, true), [k]); if (!enabled) return { data: null, error: "", loading: false, reload }; const e = entryOf(k); return { data: e.data, error: e.error, loading: e.loading || (!e.data && !e.error), reload }; } type QueueOp = "request.pick" | "request.hold" | "request.round" | "request.collected" | "request.reply" | "request.reassign" | "request.withdraw" | "damage.handedIn" | "dispute.resolve" | "kitcheck.open" | "kitcheck.close" | "waitlist.offer"; /** Runs a request-queue op through mutate(), then reload(). Returns ok. */ export function useRequestActions(reload: () => Promise): { act: (op: QueueOp, payload: Record) => Promise; error: string; clearError: () => void } { const { mutate } = useSnap(); const [error, setError] = useState(""); const act = useCallback(async (op: QueueOp, payload: Record) => { setError(""); const r = await mutate(op, payload); if (!r.ok) { setError(r.error); return false; } // Every screen sharing this cache key sees the change, not just the one that made it. await reload(); return true; }, [mutate, reload]); const clearError = useCallback(() => setError(""), []); return { act, error, clearError }; } /* ---------- the list ---------- */ export type RequestListProps = { /** Scope. staffId fetches ?staff= unless `data` is passed; ward filters client-side on r.ward. */ staffId?: string; ward?: string; /** Which rows. Default "open". */ filter?: RequestRowFilter; /** Row expanded on first render (from ?open=). */ openId?: string | null; /** Hide the person/ward text on each row (every row is the same person). */ hidePerson?: boolean; /** Panel title; null renders rows without panel chrome. Default REQUEST_FILTER_LABEL[filter]. */ title?: React.ReactNode | null; /** One-sentence empty state. Default per filter. */ emptyText?: string; /** Called whenever data loads or changes. */ onCounts?: (counts: Record) => void; /** Use an already-loaded payload (the /app/requests page shares one hook between its segment and the list). */ data?: RequestsPayload | null; reload?: () => Promise; }; const EMPTY: Record = { todo: "Nothing approved and waiting.", noapprover: "Every waiting request has somebody to approve it.", open: "No open requests.", all: "No requests yet.", }; const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; /* Row layout rules. Kept with the component (and hoisted once by React) because only the list * wears them; the mobile rule stretches an expanded row's actions to full width. */ /** The row styles, rendered once however many lists are on screen. */ export function RequestStyles() { return null; // the rules are in app/globals.css under portal redesign } export default function RequestList({ staffId, ward, filter = "open", openId, hidePerson, title, emptyText, onCounts, data: given, reload: givenReload }: RequestListProps) { const external = given !== undefined; const own = useRequests({ staffId, enabled: !external }); const data = external ? given : own.data; const reload = external ? givenReload ?? own.reload : own.reload; const { act, error } = useRequestActions(reload); const [openRow, setOpenRow] = useState(openId ?? null); useEffect(() => { if (openId) setOpenRow(openId); }, [openId]); // Bring a deep-linked row into view once it is on screen. const scrolled = useRef(null); useEffect(() => { if (!openId || scrolled.current === openId || !data) return; const el = document.getElementById(`req-${openId}`); if (el) { scrolled.current = openId; el.scrollIntoView({ block: "center" }); } }, [openId, data, filter]); const scope = useMemo(() => ({ staffId, ward }), [staffId, ward]); const counts = useMemo(() => (data ? requestCounts(data, scope) : null), [data, scope]); const countsRef = useRef(onCounts); useEffect(() => { countsRef.current = onCounts; }, [onCounts]); useEffect(() => { if (counts) countsRef.current?.(counts); }, [counts]); const rows = useMemo(() => (data ? requestsFor(data.requests, filter, scope) : []), [data, filter, scope]); const pad = title === null ? 0 : "0 16px"; let body: React.ReactNode; if (!data) { const loadErr = external ? "" : own.error; body = loadErr ? (
) :
Loading…
; } else if (rows.length === 0) { // The loading and empty lines always keep side padding: embedded with no title (the staff // record's Requests tab) the list sits directly inside a bordered panel, and a sentence flush // against that border reads as broken. Request rows lay themselves out and keep `pad`. body =
{emptyText ?? EMPTY[filter]}
; } else { body = rows.map((r) => ( setOpenRow((o) => (o === r.id ? null : r.id))} act={act} /> )); } return (
{title === null ?
{body}
: (
{body}
)} {data?.moreRequests && (

Only the latest {data.requestLimit} are loaded — older ones are on the staff record.

)}
); } /* Who a waiting request can be handed to. The wearer may approve their own (marked as a * self-approval); the person who raised it never can, and the counter refuses that on the id. * A manager with no staff-app account cannot be asked, so they are grouped apart. */ type ApproverChoice = { id: string; reachable: boolean; label: string }; function approverChoices(s: Snapshot, r: RequestRow): ApproverChoice[] { return s.staff .filter((x) => !x.inactive && x.first && x.id !== r.raisedById && (x.id !== r.staffId || x.managerId === x.id)) .map((x) => ({ id: x.id, reachable: !!x.selfEmail, label: `${`${x.first} ${x.last}`.trim()}${x.dept ? ` · ${x.dept}` : ""}` + (x.id === r.staffId ? " · this request is theirs — self-approval" : "") + (x.selfEmail ? "" : x.selfCode && slipLive(x.selfCodeAt, s.today, s.tz) ? " · code printed, not used yet" : " · no staff-app account"), })); } function RequestRowView({ r, open, hidePerson, onToggle, act }: { r: RequestRow; open: boolean; hidePerson?: boolean; onToggle: () => void; act: (op: QueueOp, payload: Record) => Promise; }) { const { s } = useSnap(); const [reply, setReply] = useState(""); const [hold, setHold] = useState(""); const [reassign, setReassign] = useState(""); const st = statusText(r, { mine: false, first: r.staffName.split(" ")[0] }); const awaiting = r.status === "awaiting"; const orphan = isStranded(r); const onRound = r.status === "round" || r.status === "delivered"; const wearerApproves = !!r.managerId && r.managerId === r.staffId; const approval = awaiting ? (r.managerName ? `with ${r.managerName}${wearerApproves ? " · self-approval" : ""}` : "nobody asked yet") : r.status === "declined" ? (r.decision || "declined") : `${r.decision || "Approved"} by ${r.managerName}${wearerApproves ? " · self-approved" : ""}`; const choices = useMemo(() => (open && awaiting ? approverChoices(s, r) : []), [open, awaiting, s, r]); const reachable = choices.filter((c) => c.reachable); const unreachable = choices.filter((c) => !c.reachable); const picked = open && reassign ? s.staff.find((x) => x.id === reassign) ?? null : null; const refused = r.lines.filter((l) => l.status === "declined").length; function toggle() { setReply(""); setHold(""); setReassign(""); onToggle(); } async function send() { if (reply.trim() && await act("request.reply", { id: r.id, body: reply })) setReply(""); } const orderForm = ( ); return (
{r.code} {r.summary} {!hidePerson && · {r.staffName}{r.ward ? ` (${r.ward})` : ""}} {orphan && No approver} {st.label}
{[r.reason, approval, r.raisedByName ? `raised by ${r.raisedByName}` : "", formatInZone(r.createdAt, s.tz)].filter(Boolean).join(" · ")}
{open && (
{r.lines.map((l) => { const off = l.status === "declined"; return (
{l.qty} × {l.item}{l.gender && l.gender !== "Unisex" ? ` (${genderLabel(l.gender)})` : ""} — {l.size} {l.statusLabel} {off && l.declineReason && {l.declineReason}}
); })}
{r.status === "declined" ? "Nothing to pick" : awaiting ? `${plural(r.garments, "garment", "garments")} asked for` : `In the bag: ${plural(r.garments, "garment", "garments")}${refused ? ` · ${plural(refused, "declined line", "declined lines")} not picked` : ""}`}
{r.note &&

“{r.note}”

}
{awaiting && ( <> {orderForm} )} {r.status === "accepted" && } {r.status === "picking" && ( <> setHold(e.target.value)} /> )} {r.status === "ready" && ( <> Code {r.collectCode} · {plural(r.garments, "garment", "garments")}{r.holdUntil ? ` · until ${r.holdUntil}` : ""} )} {r.status === "round" && On the round to {r.ward || "the ward"} · {plural(r.garments, "garment", "garments")}} {r.status === "delivered" && Signed by {r.signerName}{r.signerRole ? `, ${r.signerRole}` : ""}{r.claimedAt ? " · collected" : " · not yet collected from the ward"}} {r.status === "collected" && Handed over at the counter} {r.status === "declined" && Declined — {r.declineReason || "no reason recorded"}} {!awaiting && r.status !== "declined" && ( <> {orderForm} )}
{awaiting && choices.length === 0 && (

Nobody on the register can approve this one — add a manager or withdraw it.

)} {picked && picked.id === r.staffId && (

{picked.first}'s own request — sending it to them is a self-approval.

)} {picked && !picked.selfEmail && (

{picked.first} can't be asked:{" "} {picked.selfCode && slipLive(picked.selfCodeAt, s.today, s.tz) ? <>the code on their record isn't used yet. : picked.selfCode ? <>the code on their record has expired. : <>no staff-app account — give them a code.}

)}

Messages

{r.messages.length === 0 &&
No messages.
} {r.messages.map((m) => (
{m.fromStaff ? m.authorName : `${m.authorName} (linen room)`} {formatInZone(m.at, s.tz)}
{m.body}
))}
setReply(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void send(); }} />

History

{r.events.map((e) => (
{e.label}{e.meta ? ` — ${e.meta}` : ""} {e.actorName} · {formatInZone(e.at, s.tz)}
))}
)}
); }