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).
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user