Files
threadcount-community/components/Shell.tsx
T
ThreadCount 057da00fd2 ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from e2d6d42 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 11:16:36 +10:00

158 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 <svg> is decoration — the label beside it is what gets read out. */
const ico = (...d: string[]) => (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="square" strokeLinejoin="miter" aria-hidden="true" focusable="false">
{d.map((p) => <path key={p} d={p} />)}
</svg>
);
/* 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 (
<div className="tc-shell">
{/* 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. */}
<a className="skip-link" href="#content">Skip to content</a>
<aside id="tc-side" className={narrow ? "tc-rail-narrow" : undefined}>
<div className="tc-rail-brand">
<span className="tc-rail-mark" aria-hidden="true" />
<span className="tc-rail-word">ThreadCount</span>
{/* aria-label rather than a visible word: collapsed there is no room for one, and a
button whose whole content is an arrow has no name at all without it. */}
<button type="button" className="tc-rail-toggle" onClick={toggleRail} aria-expanded={!narrow} aria-controls="tc-rail-nav" aria-label={narrow ? "Expand the menu" : "Collapse the menu"} title={narrow ? "Expand the menu" : "Collapse the menu"}>
{narrow ? ico("M8 6l6 6-6 6", "M15 6l6 6-6 6") : ico("M16 6l-6 6 6 6", "M9 6l-6 6 6 6")}
</button>
</div>
<div className="tc-rail-facility" title={s.settings.facility}>{s.settings.facility}</div>
<nav id="tc-rail-nav" className="tc-rail-nav" aria-label="Screens">
{SCREENS.map((sc) => {
const on = railActive(sc.href, sc.also);
return (
<Link key={sc.href} href={sc.href} className={"tc-rail-item" + (on ? " active" : "")} aria-current={on ? "page" : undefined}>
<span className="tc-rail-icon">{sc.icon}</span>
<span className="tc-rail-label">{sc.label}</span>
</Link>
);
})}
</nav>
<div className="tc-rail-foot">
{/* Where you are and what day it is. Collapsed this goes: it is context, not navigation,
and the same two facts are on the screen behind the rail. */}
<div className="tc-rail-meta">
<div>{s.settings.location}</div>
<div>{fmtDate(s.today)}</div>
</div>
<Link href="/app/settings?tab=account" className="tc-rail-item" title="Edit your profile">
<span className="tc-rail-icon">{ICON_BADGE}</span>
<span className="tc-rail-label">{s.session.name}<span className="tc-rail-role">{s.session.role}</span></span>
</Link>
<button type="button" className="tc-rail-item" onClick={signOut} disabled={out === "busy"}>
<span className="tc-rail-icon">{ICON_OUT}</span>
<span className="tc-rail-label">{out === "busy" ? "Signing out…" : "Sign out"}</span>
</button>
<LiveRegion tone="alert" className="tc-rail-live" msg={out === "err" ? "Network error — you are still signed in." : ""} />
</div>
</aside>
<nav id="tc-mobilebar" style={{ position: "fixed", bottom: 0, left: 0, right: 0, zIndex: 60, background: "var(--color-bg)", borderTop: "2px solid var(--color-text)", gridTemplateColumns: "repeat(4, 1fr)" }}>
{MOB_MAIN.map(([href, label, also]) => <Link key={href} href={href} style={mobBtn(active(href, also))}>{label}</Link>)}
<button style={mobBtn(moreActive || more)} onClick={() => setMore(!more)} aria-expanded={more}>More</button>
</nav>
<button id="tc-scanfab" onClick={fabScan} title="Scan a barcode" style={{ position: "fixed", right: 16, bottom: 76, zIndex: 61, width: 60, height: 60, border: "2px solid var(--color-text)", background: "var(--color-accent-600)", color: "#fff", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 11, letterSpacing: "0.06em", cursor: "pointer", alignItems: "center", justifyContent: "center", boxShadow: "var(--shadow-md)" }}>SCAN</button>
{more && (
<div onClick={() => setMore(false)} style={{ position: "fixed", inset: 0, background: "color-mix(in srgb, #201e1d 45%, transparent)", zIndex: 62, display: "flex", alignItems: "flex-end" }}>
<div style={{ background: "var(--color-bg)", borderTop: "2px solid var(--color-text)", width: "100%", padding: "var(--space-3) var(--space-3) var(--space-6)" }}>
{MOB_MORE.map(([href, label, also]) => <Link key={href} href={href} style={{ display: "block", width: "100%", textAlign: "left", border: "none", borderBottom: "1px solid var(--color-divider)", background: active(href, also) ? "var(--color-text)" : "var(--color-bg)", color: active(href, also) ? "var(--color-bg)" : "var(--color-text)", fontWeight: active(href, also) ? 700 : 500, fontSize: 15, padding: "15px 12px", textDecoration: "none" }}>{label}</Link>)}
</div>
</div>
)}
{/* 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. */}
<main id="content" tabIndex={-1} className="tc-main"><DemoBanner /><PlanBanner />{children}</main>
</div>
);
}