"use client"; /* The search-and-scan panel: people, their waiting bags, the requests they approve, garments and * orders, one keyboard list. Opened from the top bar, "/", Ctrl/Cmd+K, an unknown scan, or the * phone's SCAN button (camera mode). */ import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useDerived, useSnap } from "@/lib/client"; import { addDays, facilityDate, formatInZone, isOverdue, label, type Snapshot } from "@/lib/compute"; import { resolveScan, searchPortal, type SearchHit, type SearchRequest } from "@/lib/search"; import { Icon, Kbd, Tag } from "@/components/portal"; import { LiveRegion } from "@/components/ui"; import { BindDialog } from "@/components/dialogs"; import Camera from "@/components/Camera"; /* The request queue, fetched when the panel first opens and reused for a minute. A failure leaves the Approves group out rather than showing an error in a search box. */ let requestCache: { at: number; rows: SearchRequest[] } | null = null; async function loadRequests(): Promise { if (requestCache && Date.now() - requestCache.at < 60_000) return requestCache.rows; try { const r = await fetch("/api/requests"); if (!r.ok) return null; const j = (await r.json()) as { requests?: SearchRequest[] }; const rows = Array.isArray(j.requests) ? j.requests : []; requestCache = { at: Date.now(), rows }; return rows; } catch { return null; } } type Row = { id: string; hit: SearchHit; primary: () => void; secondary?: () => void }; const GROUPS: { key: "person" | "waiting" | "approves" | "garment" | "order"; label: string }[] = [ { key: "person", label: "Person" }, { key: "waiting", label: "Waiting" }, { key: "approves", label: "Approves" }, { key: "garment", label: "Garments" }, { key: "order", label: "Orders" }, ]; const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; function whenLabel(s: Snapshot, iso: string | null): string { if (!iso) return ""; const d = facilityDate(iso, s.tz); if (!d) return ""; if (d === s.today) return "today"; if (d === addDays(s.today, -1)) return "yesterday"; return formatInZone(d, s.tz, { day: "numeric", month: "short" }).replace("Sept", "Sep"); } export default function CommandBar({ open, onClose, initialQuery = "", camera = false, onScan, unknownCode }: { open: boolean; onClose: () => void; initialQuery?: string; camera?: boolean; /** Where a scan from the camera, or a scanned code typed into the box, is routed. Shell's routeScan. */ onScan?: (code: string) => void; /** Set when the panel was opened by a scan nothing matched. */ unknownCode?: string; }) { if (!open) return null; if (camera) return { onClose(); onScan?.(raw); }} />; return ; } function Panel({ onClose, initialQuery, onScan, unknownCode }: { onClose: () => void; initialQuery: string; onScan?: (code: string) => void; unknownCode?: string }) { const { s, isAdmin, mutate, busy } = useSnap(); const d = useDerived(); const router = useRouter(); const [q, setQ] = useState(initialQuery); const [active, setActive] = useState(0); const [requests, setRequests] = useState(requestCache?.rows); const [msg, setMsg] = useState<{ tone: "status" | "alert"; text: string } | null>(null); const [binding, setBinding] = useState(null); const box = useRef(null); const input = useRef(null); const listId = useId(); const opener = useRef(null); if (opener.current === null && typeof document !== "undefined") opener.current = document.activeElement; useEffect(() => { let live = true; loadRequests().then((rows) => { if (live && rows) setRequests(rows); }); return () => { live = false; }; }, []); // Modal: everything behind the panel leaves the tab order and the accessibility tree, as ui Dialog does. useEffect(() => { const node = box.current; if (!node) return; const opened = opener.current; const off: HTMLElement[] = []; for (let el: HTMLElement | null = node.parentElement; el && el !== document.body && el.parentElement; el = el.parentElement) { for (const sib of Array.from(el.parentElement.children)) { if (sib !== el && sib instanceof HTMLElement && !sib.inert) { sib.inert = true; off.push(sib); } } } input.current?.focus(); input.current?.select(); return () => { for (const el of off) el.inert = false; const back = opened instanceof HTMLElement && opened.isConnected && opened !== document.body ? opened : document.getElementById("tc-search-trigger"); back?.focus(); }; }, []); const groups = useMemo(() => searchPortal(s, d, q, requests), [s, d, q, requests]); const staffById = d.staffById; const go = (href: string) => { onClose(); router.push(href); }; async function pickedUp(id: string, what: string) { setMsg(null); const r = await mutate("pickup.pickedUp", { id }); setMsg(r.ok ? { tone: "status", text: `Picked up: ${what}.` } : { tone: "alert", text: r.error }); } const rows: Row[] = []; const byGroup: Record = {}; for (const g of GROUPS) { byGroup[g.key] = groups[g.key].map((hit, i) => { const id = `${listId}-${g.key}-${i}`; let row: Row; switch (hit.kind) { case "person": row = { id, hit, primary: () => go(`/app/counter?staff=${encodeURIComponent(hit.staff.id)}`), secondary: () => go(`/app/staff/${encodeURIComponent(hit.staff.id)}`) }; break; case "waiting": { const first = hit.pickup.lines[0]; const what = first ? `${label(d.byId[first.itemId])} ${first.size}` : hit.pickup.orderCode; row = { id, hit, primary: () => { void pickedUp(hit.pickup.id, what); } }; break; } case "approves": row = { id, hit, primary: () => go(`/app/requests?open=${encodeURIComponent(hit.request.id)}`) }; break; case "garment": row = { id, hit, primary: () => go(`/app/stock/${encodeURIComponent(hit.item.id)}`) }; break; default: row = { id, hit, primary: () => go(`/app/orders/${encodeURIComponent(hit.order.id)}`) }; } rows.push(row); return row; }); } const at = Math.min(active, Math.max(0, rows.length - 1)); const current = rows[at]; useEffect(() => { setActive(0); }, [q]); useEffect(() => { if (!current) return; document.getElementById(current.id)?.scrollIntoView({ block: "nearest" }); }, [current]); function onKey(e: React.KeyboardEvent) { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); onClose(); return; } if (e.key === "ArrowDown" || e.key === "ArrowUp") { if (!rows.length) return; e.preventDefault(); setActive((a) => (Math.min(a, rows.length - 1) + (e.key === "ArrowDown" ? 1 : rows.length - 1)) % rows.length); return; } if (e.key === "Enter" && e.target === input.current) { e.preventDefault(); const scan = resolveScan(s, q); // A garment barcode typed or scanned into the box goes straight to it: no row carries barcodes. if (scan.kind === "garment" && onScan) { onClose(); onScan(q.trim()); return; } if (!current) { if (scan.kind === "staff" && onScan) { onClose(); onScan(q.trim()); } return; } if (e.shiftKey && current.secondary) current.secondary(); else current.primary(); return; } if (e.key === "Tab") { const node = box.current; if (!node) return; const f = Array.from(node.querySelectorAll(FOCUSABLE)).filter((el) => el.getClientRects().length > 0); if (!f.length) return; const now = document.activeElement; if (e.shiftKey && now === f[0]) { e.preventDefault(); f[f.length - 1].focus(); } else if (!e.shiftKey && now === f[f.length - 1]) { e.preventDefault(); f[0].focus(); } } } if (binding !== null) { return ( { onClose(); router.push(`/app/stock/${encodeURIComponent(itemId)}?size=${si}`); }} /> ); } const trimmed = q.trim(); const showUnknown = !!unknownCode && trimmed === unknownCode.trim(); const empty = !trimmed; const cap = s.settings.capSets; const rowBody = (row: Row, on: boolean) => { const h = row.hit; switch (h.kind) { case "person": { const st = h.staff; const bits = [st.group, st.dept, `holds ${h.held.sets} of ${h.cap || cap} sets`]; if (h.waitingBags > 0) bits.push(`${plural(h.waitingBags, "bag", "bags")} waiting ${plural(h.oldestWaitDays ?? 0, "day", "days")}`); if (st.inactive) bits.push("inactive"); return ( <>
{st.first} {st.last} {st.num}
{bits.filter(Boolean).join(" · ")}. Enter for the counter, Shift+Enter for the record.
); } case "waiting": { const p = h.pickup; const first = p.lines[0]; const more = p.lines.length > 1 ? ` +${p.lines.length - 1}` : ""; const name = h.staff ? `${h.staff.first} ${h.staff.last}` : "someone no longer on the register"; return ( <>
{first ? <>{label(d.byId[first.itemId])} · {first.size}{more} : "A bag"} for {name}
{[p.orderCode, `${plural(h.days, "day", "days")} at the counter`].filter(Boolean).join(" · ")}
); } case "approves": { const r = h.request; const firstName = (r.managerName || "").trim().split(/\s+/)[0] || (staffById[r.managerId || ""]?.first ?? ""); const when = whenLabel(s, r.decidedAt); return ( <>
{r.code} · {r.staffName}
approved by {firstName}{when ? ` ${when}` : ""} · ready to pick
); } case "garment": return (
{label(h.item)}{h.item.sku && <> {h.item.sku}}
{[h.item.supplier, `${h.onhand} on hand`].filter(Boolean).join(" · ")}
); default: { const o = h.order; const late = isOverdue(o, s.today); const tone = late ? "accent" : o.status === "Received" || o.status === "Cancelled" ? "quiet" : "outline"; const exp = o.expected ? `expected ${formatInZone(o.expected, s.tz, { day: "numeric", month: "short" }).replace("Sept", "Sep")}` : ""; return (
{o.code} {late ? "Overdue" : o.status}
{[o.supplier, exp].filter(Boolean).join(" · ")}
); } } }; return (
{ if (e.target === e.currentTarget) onClose(); }}>
setQ(e.target.value)} aria-label="Search people, garments and orders" role="combobox" aria-expanded={rows.length > 0} aria-controls={listId} aria-activedescendant={current ? current.id : undefined} aria-describedby={`${listId}-keys`} aria-autocomplete="list" autoComplete="off" spellCheck={false} />
Arrow keys move through results. Enter opens the result; for a person, Enter opens the counter and Shift+Enter opens their record. {showUnknown && (
No person or garment has {unknownCode}. {isAdmin && }
)}
{GROUPS.map((g) => byGroup[g.key].length > 0 && (
{byGroup[g.key].map((row) => { const on = row === current; return (
{ const i = rows.indexOf(row); if (i !== at) setActive(i); }} onClick={() => row.primary()}> {rowBody(row, on)}
); })}
))} {!empty && !showUnknown && rows.length === 0 &&
Nothing matches “{trimmed}”.
}
); }