import { createRootRouteWithContext, createRoute, createRouter, Link, Outlet, redirect, } from "@tanstack/react-router"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import type { Lang, Permission, SessionUser, Theme } from "./api.js"; import { can, closeShift, logout, openShift, setLanguagePref, setThemePref } from "./api.js"; import { qk, queryClient } from "./lib/query.js"; import { setLanguage } from "./lib/i18n/index.js"; import { applyTheme } from "./lib/theme.js"; import { useLiveFeed } from "./lib/use-live-feed.js"; import { useShift } from "./lib/use-shift.js"; import { DeviceFooter } from "./ui/DeviceFooter.js"; import { StatusDot } from "./ui/StatusDot.js"; import { BoothScreen } from "./BoothScreen.js"; import { SetupWizard } from "./SetupWizard.js"; import { TariffComposer } from "./TariffComposer.js"; import { SubscriptionManager } from "./SubscriptionManager.js"; import { ShiftControl } from "./ShiftControl.js"; import { SiteSettings } from "./SiteSettings.js"; import { UsersManager } from "./UsersManager.js"; import { RolesManager } from "./RolesManager.js"; import { ShiftsHistory } from "./ShiftsHistory.js"; import { LogsViewer } from "./LogsViewer.js"; // Code-based TanStack Router (no file-based codegen — the app is small enough that // an explicit tree is clearer). The router context carries the signed-in user and // a setter so route guards can redirect by role. The root renders the terminal // chrome (nav + user + live status) and opens the booth WebSocket once, app-wide. export interface RouterContext { user: SessionUser | null; setUser: (u: SessionUser | null) => void; } const rootRoute = createRootRouteWithContext()({ component: RootLayout, }); function NavLink({ to, label }: { to: string; label: string }) { return ( {label} ); } /** A tab inside the Setup layout. `exact` (activeOptions) so the Devices tab at * `/setup` doesn't stay highlighted on the child tabs. */ function SetupTab({ to, label, exact = false }: { to: string; label: string; exact?: boolean }) { return ( {label} ); } /** Setup layout — the config hub. Renders a permission-gated tab bar and the active * tab's screen via . Each tab is a child route (its own URL + guard), so * deep links and the back button work and a denied tab redirects to the booth. */ function SetupLayout() { const { user } = rootRoute.useRouteContext(); const { t } = useTranslation(); const show = (perm: Permission) => can(user, perm); return (
); } /** SQ/EN toggle. Persists the choice to the user's profile (restored on next login) * and applies it immediately. Updates the router-context user so App re-syncs. */ function LanguageToggle({ user, setUser, }: { user: SessionUser; setUser: (u: SessionUser | null) => void; }) { // The ACTIVE language is i18n's own state, not the router-context `user` — the // latter is captured at route-resolution time and does NOT re-render when we call // setUser, so reading `user.language` here goes stale after the first switch (the // highlight froze and the equality guard blocked switching back until a refresh). // useTranslation() subscribes to i18n's languageChanged, so this stays live. const { i18n } = useTranslation(); const active = i18n.language as Lang; async function pick(lang: Lang) { if (lang === active) return; setLanguage(lang); // instant UI (fires i18n languageChanged → re-render) setUser({ ...user, language: lang }); // keep context eventually-consistent + persisted state try { await setLanguagePref(lang); // persist } catch { /* non-fatal — the choice still applies this session */ } } return (
{(["sq", "en"] as const).map((l) => ( ))}
); } /** Dark/light theme toggle. Same shape as the language toggle: applies instantly, * persists to the user's profile, and updates the router-context user so App * re-syncs. Restored on the next login from any booth. */ function ThemeToggle({ user, setUser, }: { user: SessionUser; setUser: (u: SessionUser | null) => void; }) { const { t } = useTranslation(); // Local state for the ACTIVE theme — same reason as LanguageToggle: the router // context `user` doesn't re-render on setUser, so reading `user.theme` here froze // the highlight after one switch and blocked toggling back until a refresh. Seed // from the prop; update optimistically on pick. App's effect keeps the DOM in sync // with the persisted user on (re)login. const [active, setActive] = useState(user.theme); async function pick(theme: Theme) { if (theme === active) return; setActive(theme); applyTheme(theme); // instant UI setUser({ ...user, theme }); // keep context eventually-consistent + persisted state try { await setThemePref(theme); // persist } catch { /* non-fatal — the choice still applies this session */ } } return (
{(["dark", "light"] as const).map((th) => ( ))}
); } /** * Header shift control — the site-wide single-open shift expressed as one button: * - no shift open → "Open shift" (enabled; opens this operator's shift) * - my shift open → "Close shift" (enabled; signs + prints the Z-report) * - another's shift open → disabled, labelled with who holds it (you can neither * open yours nor close theirs until they hand over). * On open/close it invalidates the shift status, the per-shift log, and occupancy. */ function ShiftButton() { const { t } = useTranslation(); const qc = useQueryClient(); const { isOpen, isMine, blockedByOther, heldBy } = useShift(); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); async function act(kind: "open" | "close") { setBusy(true); setErr(null); try { if (kind === "open") await openShift(); else await closeShift(); // The shift boundary moves: refresh status, the per-shift log window, drawer. void qc.invalidateQueries({ queryKey: qk.shift }); void qc.invalidateQueries({ queryKey: qk.events }); void qc.invalidateQueries({ queryKey: qk.occupancy }); } catch (e) { setErr((e as Error).message); } finally { setBusy(false); } } // Disabled when another operator holds the shift (can't open or close). const label = blockedByOther ? t("shift.headerHeldByShort", { operator: heldBy ?? "?" }) : isMine ? t("shift.headerClose") : t("shift.headerOpen"); const tone = blockedByOther ? "border-term-border text-term-muted opacity-60 cursor-not-allowed" : isMine ? "border-term-red text-term-red hover:bg-term-red/10" : "border-term-green text-term-green hover:bg-term-green/10"; return (
{!isOpen && ( {t("shift.headerNoShift")} )} {err && {err}}
); } function RootLayout() { const { user, setUser } = rootRoute.useRouteContext(); const { t } = useTranslation(); // One app-wide WebSocket for the live feed (booth + any live widget). useLiveFeed(); // Nav is gated by PERMISSION, not role — a tab shows iff the user's role grants // the permission its screen needs (the route guards enforce the same server-side). const show = (perm: Permission) => can(user, perm); return (
▮ Parking
{user && } {user && } {user && } {user?.username} · {user?.roleName}
{/* Fixed device-status footer — relays, readers, cameras, printers. */} {user && }
); } const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/", beforeLoad: () => { throw redirect({ to: "/booth" }); }, }); const boothRoute = createRoute({ getParentRoute: () => rootRoute, path: "/booth", component: BoothScreen, }); // Back-compat: the config screens used to be top-level routes. They now live under // /setup as tabs — redirect the old paths so existing bookmarks/links don't 404. const legacyRedirects = ( [ ["/tariff", "/setup/tariff"], ["/subscriptions", "/setup/subscriptions"], ["/site", "/setup/site"], ["/users", "/setup/users"], ["/roles", "/setup/roles"], ] as const ).map(([from, to]) => createRoute({ getParentRoute: () => rootRoute, path: from, beforeLoad: () => { throw redirect({ to }); }, }), ); const shiftRoute = createRoute({ getParentRoute: () => rootRoute, path: "/shift", component: function ShiftRoute() { const { user } = rootRoute.useRouteContext(); // "Admin" actions on the shift screen (drawer cash) need shift:cash. return ; }, }); /** Guard factory: a route requiring `perm` redirects a user who lacks it back to * the booth. Same permission the server enforces — defence in depth, not the only * gate. */ function requirePerm(perm: Permission) { return (ctx: RouterContext) => { if (!can(ctx.user, perm)) throw redirect({ to: "/booth" }); }; } // The Setup tabs in display order, each with the permission its screen needs. Used // to land a user on the FIRST tab they may see when they open /setup without // `site:update` (e.g. an operator who only has shift:read → goes to /setup/shifts). const SETUP_TABS: { to: string; perm: Permission }[] = [ { to: "/setup", perm: "site:update" }, { to: "/setup/tariff", perm: "tariff:read" }, { to: "/setup/subscriptions", perm: "subscription:read" }, { to: "/setup/site", perm: "site:read" }, { to: "/setup/users", perm: "user:read" }, { to: "/setup/roles", perm: "role:read" }, { to: "/setup/shifts", perm: "shift:read" }, { to: "/setup/logs", perm: "log:read" }, ]; // /setup is a LAYOUT route (tab bar + ); the config screens are its // children. The layout itself has no permission gate — each child enforces its own // (so a user who can reach ANY tab gets the hub, but only the tabs they're allowed). const setupRoute = createRoute({ getParentRoute: () => rootRoute, path: "/setup", component: SetupLayout, }); // Index tab = Devices (the former SetupWizard). Lives at /setup exactly. A user who // lacks site:update (e.g. an operator) is redirected to the FIRST tab they CAN see // rather than bounced to the booth — so "Setup" always lands somewhere useful. const setupDevicesRoute = createRoute({ getParentRoute: () => setupRoute, path: "/", beforeLoad: ({ context }) => { if (can(context.user, "site:update")) return; const firstOther = SETUP_TABS.find((tab) => tab.to !== "/setup" && can(context.user, tab.perm)); throw redirect({ to: firstOther?.to ?? "/booth" }); }, component: () => , }); const tariffRoute = createRoute({ getParentRoute: () => setupRoute, path: "tariff", beforeLoad: ({ context }) => requirePerm("tariff:read")(context), component: () => , }); const subscriptionsRoute = createRoute({ getParentRoute: () => setupRoute, path: "subscriptions", beforeLoad: ({ context }) => requirePerm("subscription:read")(context), component: () => , }); const siteRoute = createRoute({ getParentRoute: () => setupRoute, path: "site", beforeLoad: ({ context }) => requirePerm("site:read")(context), component: function SiteRoute() { const { user } = rootRoute.useRouteContext(); return ; }, }); const usersRoute = createRoute({ getParentRoute: () => setupRoute, path: "users", beforeLoad: ({ context }) => requirePerm("user:read")(context), component: function UsersRoute() { const { user } = rootRoute.useRouteContext(); return ; }, }); const rolesRoute = createRoute({ getParentRoute: () => setupRoute, path: "roles", beforeLoad: ({ context }) => requirePerm("role:read")(context), component: function RolesRoute() { const { user } = rootRoute.useRouteContext(); return ; }, }); // Shift history. Gated by shift:read (operators have it) — the SERVER scopes the // data: operators see only their own; shift:cash holders see all + can filter. const shiftsHistoryRoute = createRoute({ getParentRoute: () => setupRoute, path: "shifts", beforeLoad: ({ context }) => requirePerm("shift:read")(context), component: function ShiftsHistoryRoute() { const { user } = rootRoute.useRouteContext(); return ; }, }); // Diagnostic logs. Gated by log:read (an admin/diagnostic permission). const logsRoute = createRoute({ getParentRoute: () => setupRoute, path: "logs", beforeLoad: ({ context }) => requirePerm("log:read")(context), component: LogsViewer, }); const routeTree = rootRoute.addChildren([ indexRoute, boothRoute, ...legacyRedirects, shiftRoute, setupRoute.addChildren([ setupDevicesRoute, tariffRoute, subscriptionsRoute, siteRoute, usersRoute, rolesRoute, shiftsHistoryRoute, logsRoute, ]), ]); export const router = createRouter({ routeTree, context: { user: null, setUser: () => {} }, defaultPreload: "intent", }); declare module "@tanstack/react-router" { interface Register { router: typeof router; } }