"use client"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useDerived, useSnap } from "@/lib/client"; import { daysBetween, facilityDate, formatInZone } from "@/lib/compute"; import { PortalCountsContext, portalCounts, type ServerCounts } from "@/lib/portalcounts"; import { resolveScan } from "@/lib/search"; import DemoBanner from "@/components/DemoBanner"; import PlanBanner from "@/components/PlanBanner"; import CommandBar from "@/components/CommandBar"; import { Icon, Kbd, type IconName } from "@/components/portal"; import { LiveRegion } from "@/components/ui"; /* Seven screens. Each is active on its own whole path segments and on the older screens folded into it, so /app/stocktake lights Stock and /app/requests lights People. */ type Screen = { key: string; href: string; label: string; icon: IconName; on: string[] }; const SCREENS: Screen[] = [ { key: "today", href: "/app", label: "Today", icon: "today", on: ["/app/rounds"] }, { key: "counter", href: "/app/counter", label: "Counter", icon: "counter", on: ["/app/issue"] }, { key: "stock", href: "/app/stock", label: "Stock", icon: "stock", on: ["/app/stocktake"] }, { key: "orders", href: "/app/orders", label: "Orders", icon: "orders", on: [] }, { key: "people", href: "/app/staff", label: "People", icon: "people", on: ["/app/requests"] }, { key: "reports", href: "/app/report", label: "Reports", icon: "reports", on: [] }, { key: "settings", href: "/app/settings", label: "Settings", icon: "settings", on: ["/app/activity", "/app/checkout"] }, ]; type Badge = { text: string; tone: "accent" | "quiet"; sr: string }; type CmdState = { open: boolean; query: string; camera: boolean; unknown?: string }; const RAIL_OUT = ( ); const RAIL_ME = ( ); const isEditable = (el: Element | null) => !!el && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement || (el instanceof HTMLElement && el.isContentEditable)); const dialogOpen = () => !!document.querySelector('[aria-modal="true"]'); const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; export default function Shell({ children, serverCounts }: { children: React.ReactNode; serverCounts: ServerCounts }) { const { s, isAdmin } = useSnap(); const d = useDerived(); const path = usePathname() || "/app"; const router = useRouter(); const counts = useMemo(() => portalCounts(s, d, serverCounts, isAdmin), [s, d, serverCounts, isAdmin]); const inSeg = (h: string) => path === h || path.startsWith(h + "/"); const screenOn = (sc: Screen) => (sc.href === "/app" ? path === "/app" : inSeg(sc.href)) || sc.on.some(inSeg); const backupStale = (() => { const last = s.settings.lastBackup ? facilityDate(s.settings.lastBackup, s.tz) : ""; return !last || daysBetween(last, s.today) > 7; })(); const badges: Record = { today: counts.today.total > 0 ? { text: String(counts.today.total), tone: counts.today.overdue > 0 ? "accent" : "quiet", sr: `, ${plural(counts.today.total, "thing needs", "things need")} a person${counts.today.overdue > 0 ? `, ${counts.today.overdue} overdue` : ""}` } : null, counter: null, stock: counts.stock.garmentsAtReorder > 0 ? { text: String(counts.stock.garmentsAtReorder), tone: "quiet", sr: `, ${plural(counts.stock.garmentsAtReorder, "garment", "garments")} at reorder` } : null, orders: counts.orders.overdue > 0 ? { text: String(counts.orders.overdue), tone: "accent", sr: `, ${plural(counts.orders.overdue, "order", "orders")} overdue` } : isAdmin && counts.orders.toOrderLines > 0 ? { text: String(counts.orders.toOrderLines), tone: "quiet", sr: `, ${plural(counts.orders.toOrderLines, "line", "lines")} to order` } : null, people: counts.people.attention > 0 ? { text: String(counts.people.attention), tone: counts.people.stranded > 0 ? "accent" : "quiet", sr: `, ${plural(counts.people.attention, "item needs", "items need")} attention${counts.people.stranded > 0 ? `, ${plural(counts.people.stranded, "request has", "requests have")} no approver` : ""}` } : null, reports: null, settings: isAdmin && backupStale ? { text: "!", tone: "quiet", sr: ", backup overdue" } : null, }; /* Collapsing the rail is a habit of the machine, not the account, so it lives in localStorage, read after mount so the server's HTML and the first paint agree. */ const [narrow, setNarrow] = useState(false); useEffect(() => { try { setNarrow(localStorage.getItem("tc.rail") === "narrow"); } catch { /* storage is off: the rail starts open */ } }, []); function toggleRail() { setNarrow((n) => { const next = !n; try { localStorage.setItem("tc.rail", next ? "narrow" : "wide"); } catch { /* nothing to remember it with */ } return next; }); } const [more, setMore] = useState(false); useEffect(() => { setMore(false); }, [path]); // The chat bubble is hidden under 780px (globals.css), so More offers chat once the widget has loaded. const [chat, setChat] = useState(false); useEffect(() => { if (more) setChat(!!window.$chatwoot); }, [more]); const [cmd, setCmd] = useState({ open: false, query: "", camera: false }); const openCmd = useCallback((over: Partial = {}) => setCmd({ open: true, query: "", camera: false, ...over }), []); const closeCmd = useCallback(() => setCmd({ open: false, query: "", camera: false }), []); const routeScan = useCallback((raw: string) => { const code = raw.trim(); if (!code) return; const t = resolveScan(s, code); if (t.kind === "staff") { router.push(`/app/counter?staff=${encodeURIComponent(t.staffId)}`); return; } if (t.kind === "garment") { const sp = new URLSearchParams(window.location.search); const here = window.location.pathname; const counterWithPerson = (here === "/app/counter" || here === "/app/issue") && !!sp.get("staff"); const counting = (here === "/app/stock" && sp.get("tab") === "count") || here === "/app/stocktake"; if (counterWithPerson || counting) window.dispatchEvent(new CustomEvent("tc-scan-garment", { detail: { itemId: t.itemId, si: t.si } })); else router.push(`/app/stock/${encodeURIComponent(t.itemId)}?size=${t.si}`); return; } openCmd({ query: code, unknown: code }); }, [s, router, openCmd]); /* "/" and Ctrl/Cmd+K open the panel. A hardware scanner types fast and ends in Enter: printable keys no more than 35ms apart, at least four of them, outside any field and any dialog. The counter's and the count's own scan boxes handle scans typed into them. */ const cmdOpen = cmd.open; const scanBuf = useRef<{ chars: string; last: number }>({ chars: "", last: 0 }); useEffect(() => { const onKey = (e: KeyboardEvent) => { const buf = scanBuf.current; if (cmdOpen || isEditable(document.activeElement) || dialogOpen()) { buf.chars = ""; return; } const now = e.timeStamp || performance.now(); if (e.key === "Enter") { const fast = buf.chars.length >= 4 && now - buf.last <= 35; const code = buf.chars; buf.chars = ""; if (fast) { e.preventDefault(); e.stopPropagation(); routeScan(code); } return; } if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { buf.chars = buf.chars && now - buf.last <= 35 ? buf.chars + e.key : e.key; buf.last = now; return; } if (e.key !== "Shift") buf.chars = ""; }; const onShortcut = (e: KeyboardEvent) => { if (cmdOpen || e.defaultPrevented) return; const slash = e.key === "/" && !e.ctrlKey && !e.metaKey && !e.altKey; const k = (e.key === "k" || e.key === "K") && (e.ctrlKey || e.metaKey) && !e.altKey; if (!slash && !k) return; if (isEditable(document.activeElement) || dialogOpen()) return; e.preventDefault(); openCmd(); }; window.addEventListener("keydown", onKey, true); window.addEventListener("keydown", onShortcut); return () => { window.removeEventListener("keydown", onKey, true); window.removeEventListener("keydown", onShortcut); }; }, [cmdOpen, routeScan, openCmd]); // The clock: display only, facility zone, after mount, every 30 seconds. const [now, setNow] = useState(null); useEffect(() => { setNow(new Date()); const t = window.setInterval(() => setNow(new Date()), 30_000); return () => window.clearInterval(t); }, []); const clock = now ? formatInZone(now, s.tz, { weekday: "short", day: "numeric", month: "short", year: "numeric" }).replace(/,/g, "").replace("Sept", "Sep") + " · " + formatInZone(now, s.tz, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }) : ""; function fabScan() { setMore(false); const tab = new URLSearchParams(window.location.search).get("tab"); if (path === "/app/counter" || path === "/app/issue" || path === "/app/stocktake" || (path === "/app/stock" && tab === "count")) window.dispatchEvent(new CustomEvent("tc-scan")); else openCmd({ camera: true }); } /* The session cookie is httpOnly, so only the server can end a session. If the request never lands, say so and leave the button to try again. */ const [out, setOut] = useState<"" | "busy" | "err">(""); async function signOut() { if (out === "busy") return; setOut("busy"); const ok = await fetch("/api/auth/logout", { method: "POST" }).then((r) => r.ok).catch(() => false); if (!ok) { setOut("err"); return; } router.push("/auth"); router.refresh(); } const mobMain: { href: string; label: string; on: boolean }[] = [ { href: "/app", label: "Today", on: path === "/app" || inSeg("/app/rounds") }, { href: "/app/counter", label: "Counter", on: inSeg("/app/counter") || inSeg("/app/issue") }, { href: "/app/stock", label: "Stock", on: inSeg("/app/stock") || inSeg("/app/stocktake") }, ]; const mobMore: { href: string; label: string; badge: Badge | null; on: boolean }[] = [ { href: "/app/orders", label: "Orders", badge: badges.orders, on: inSeg("/app/orders") }, { href: "/app/staff", label: "People", badge: badges.people, on: inSeg("/app/staff") }, { href: "/app/requests", label: "Requests", badge: counts.people.stranded > 0 ? { text: String(counts.people.stranded), tone: "accent", sr: `, ${plural(counts.people.stranded, "request has", "requests have")} no approver` } : null, on: inSeg("/app/requests") }, { href: "/app/rounds", label: "Delivery rounds", badge: counts.today.groups.round > 0 ? { text: String(counts.today.groups.round), tone: "quiet", sr: `, ${plural(counts.today.groups.round, "bag", "bags")} for the round` } : null, on: inSeg("/app/rounds") }, { href: "/app/report", label: "Reports", badge: null, on: inSeg("/app/report") }, { href: "/app/settings", label: "Settings", badge: badges.settings, on: inSeg("/app/settings") || inSeg("/app/activity") || inSeg("/app/checkout") }, { href: "/app/help", label: "Help", badge: null, on: inSeg("/app/help") }, { href: "/m", label: "Counter app", badge: null, on: false }, ]; const moreOn = !mobMain.some((m) => m.on); useEffect(() => { if (!more) return; const h = (e: KeyboardEvent) => { if (e.key === "Escape") setMore(false); }; window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, [more]); const badgeEl = (b: Badge | null, cls: string) => b && ( <> ); return (
Skip to content
Scanner ready · a badge opens the counter
{clock}
{children}
{more && (
setMore(false)}>
)}
); }