"use client"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { useSnap } from "@/lib/client"; import { fmtDate } from "@/lib/compute"; import DemoBanner from "@/components/DemoBanner"; import PlanBanner from "@/components/PlanBanner"; import { LiveRegion } from "@/components/ui"; /* The rail's icons are drawn here rather than pulled off a CDN: the content policy allows very few hosts, and every icon set worth loading draws with rounded caps, which would sit badly beside buttons and panels made of 2px square corners. So: 2px strokes, square caps, mitred joins, one glyph per screen, each one different enough to tell apart at 18px on a collapsed rail. The is decoration — the label beside it is what gets read out. */ const ico = (...d: string[]) => ( ); /* The screens, in the order the linen room already knows them, and Help last. Stock Take was only ever reachable from the Inventory tabs and the phone's bottom bar; with icons ordering the menu there is room to name it, and a count is not a sub-page of the shelf list. */ const SCREENS: { href: string; label: string; also: string[]; icon: React.ReactNode }[] = [ { href: "/app", label: "Dashboard", also: [], icon: ico("M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z") }, { href: "/app/stock", label: "Inventory", also: [], icon: ico("M3 4h18v16H3z", "M3 9.5h18M3 15h18") }, { href: "/app/stocktake", label: "Stock Take", also: [], icon: ico("M6 4h12v16H6z", "M9 2.5h6v3H9z", "M9 13l2 2 4-4") }, { href: "/app/issue", label: "Issue Stock", also: [], icon: ico("M9 4L3 7l2 4 3-1.5V20h8V9.5l3 1.5 2-4-6-3z", "M9 4l3 3 3-3") }, { href: "/app/rounds", label: "Delivery Rounds", also: [], icon: ico("M2 4h3l2.5 11H19", "M7 7h13l-1.5 5H8", "M8 17h2v2H8zM16 17h2v2h-2z") }, { href: "/app/orders", label: "Ordering", also: [], icon: ico("M4 13v7h16v-7", "M12 3v9", "M8 8l4 4 4-4") }, { href: "/app/report", label: "Reports", also: [], icon: ico("M4 20h16", "M6 20v-5h3v5zM11 20V8h3v12zM16 20v-8h3v8z") }, { href: "/app/staff", label: "Staff Register", also: [], icon: ico("M9 3h6v6H9z", "M4 21v-4l3-2h10l3 2v4") }, { href: "/app/requests", label: "Ward Requests", also: [], icon: ico("M3 5h18v14H3z", "M3 5l9 7 9-7") }, { href: "/app/settings", label: "Settings", also: [], icon: ico("M3 8h18M3 16h18", "M7 5h3v6H7zM14 13h3v6h-3z") }, { href: "/app/activity", label: "Activity", also: [], icon: ico("M2 12h4l3-7 4 14 3-7h6") }, { href: "/app/help", label: "Help", also: [], icon: ico("M4 4h16v16H4z", "M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.3-1 .8-1 1.5V14", "M12 17v.5") }, ]; const ICON_BADGE = ico("M5 3h14v18H5z", "M9 7h6v5H9z", "M8 16h8"); const ICON_OUT = ico("M10 4H4v16h6", "M13 12h8", "M18 9l3 3-3 3"); // Mobile: four slots (Dashboard · Issue · Stocktake · More); the rest live in the More sheet. const MOB_MAIN: [string, string, string[]][] = [["/app", "Dashboard", []], ["/app/issue", "Issue", []], ["/app/stocktake", "Stocktake", []]]; const MOB_MORE: [string, string, string[]][] = [["/app/stock", "Inventory", []], ["/app/rounds", "Delivery Rounds", []], ["/app/orders", "Ordering", []], ["/app/report", "Reports", []], ["/app/staff", "Staff Register", []], ["/app/requests", "Ward Requests", []], ["/app/settings", "Settings", []], ["/app/activity", "Activity", []], ["/m", "Counter app", []]]; export default function Shell({ children }: { children: React.ReactNode }) { const { s } = useSnap(); const path = usePathname(); const router = useRouter(); const active = (href: string, also: string[]) => (href === "/app" ? path === "/app" : path.startsWith(href) || also.some((a) => path.startsWith(a))); // The rail matches on whole path segments where the bottom bar matches on a bare prefix, because // "/app/stocktake" starts with "/app/stock": with both in the menu a prefix test lights Inventory // and Stock Take at once, and there is then nothing on screen saying which count you are in. const railActive = (href: string, also: string[]) => href === "/app" ? path === "/app" : [href, ...also].some((h) => path === h || path.startsWith(h + "/")); const [more, setMore] = useState(false); useEffect(() => { setMore(false); }, [path]); const moreActive = MOB_MORE.some(([h]) => active(h, [])); // Collapsing the rail is a habit of the machine, not of the account: the linen-room PC is shared // and its screen is short, while a manager's laptop is not. So it lives in localStorage. Read // after mount — reading it while rendering would make the server's HTML and the browser's first // paint disagree — and every touch of storage is wrapped, because a locked-down browser profile // throws on the first read rather than returning null. const [narrow, setNarrow] = useState(false); useEffect(() => { try { setNarrow(localStorage.getItem("tc.rail") === "narrow"); } catch { /* storage is off: the rail just starts open */ } }, []); function toggleRail() { setNarrow((n) => { const next = !n; try { localStorage.setItem("tc.rail", next ? "narrow" : "wide"); } catch { /* nothing to remember it with; the toggle still works for this visit */ } return next; }); } // Floating SCAN button: on Issue / Stock take the page opens its own camera; elsewhere it's a garment lookup on Inventory. function fabScan() { setMore(false); if (path.startsWith("/app/issue") || path.startsWith("/app/stocktake")) window.dispatchEvent(new CustomEvent("tc-scan")); else router.push("/app/stock?scan=1"); } const mobBtn = (on: boolean): React.CSSProperties => ({ border: "none", background: on ? "var(--color-text)" : "var(--color-bg)", color: on ? "var(--color-bg)" : "var(--color-text)", fontFamily: "var(--font-body)", fontWeight: on ? 700 : 500, fontSize: 12, padding: "14px 4px 16px", borderRight: "1px solid var(--color-divider)", cursor: "pointer", minHeight: 52, textDecoration: "none", textAlign: "center" }); // The session cookie is httpOnly, so only the server can end a session. If the request never // lands there is nothing the browser can do about it, and sending the person to /auth anyway // would bounce them straight back here (an authenticated visit to /auth redirects into the app). // So say what happened and leave the button to try again, rather than swallowing the failure and // leaving someone on a shared ward machine believing they have signed out. 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(); } return (
{/* The first thing the keyboard reaches on every screen. Without it a coordinator tabbing in walks the collapse toggle, the eleven nav items, their profile link and Sign out before touching the page. */} Skip to content {more && (
setMore(false)} style={{ position: "fixed", inset: 0, background: "color-mix(in srgb, #201e1d 45%, transparent)", zIndex: 62, display: "flex", alignItems: "flex-end" }}>
{MOB_MORE.map(([href, label, also]) => {label})}
)} {/* tabIndex −1 so the skip link actually moves focus here: following a fragment scrolls the page but leaves the keyboard where it was unless the target can hold focus. */}
{children}
); }