5c6a21e2c3
The desktop shell is one generic .deb/.AppImage distributed via mca/public_releases, not built per-booth, but the backend origin was baked in at build time (VITE_API_BASE, hardcoded to http://127.0.0.1:3000) — the same installer could never point at a different appliance without a rebuild. Adds ConnectScreen (shown before Login in Tauri when no backend is saved), backed by tauri-plugin-store persisting the operator-entered URL across restarts. CSP's connect-src tightens to 'self' only — all backend traffic already routes through tauri-plugin-http/websocket, which run Rust-side and are outside connect-src's reach anyway — and the real access boundary moves to capabilities/default.json's http:default scope, wildcarded so an operator-chosen host is actually reachable. Adds a "Change server" control in Setup (desktop-only) to repoint an already-configured install. While tracing the desktop auth path for this: tauri-plugin-http's fetch() runs through Rust's reqwest, which keeps its own cookie jar separate from the webview, so document.cookie on tauri://localhost never sees the parking_csrf cookie the server sets (open upstream bug, tauri-apps/tauri#13045/#11518). This means the desktop app has likely been silently sending no CSRF header on every mutation since the shell was first built — pre-existing, independent of this change. Fixed by having sessionView() (routes/auth.ts) also echo the CSRF value in the login/me JSON body; the desktop client stashes it in memory and echoes that instead of reading document.cookie. assertCsrf() itself is untouched. Verified end-to-end against a real LAN-bound dev server: login returns a csrfToken matching the cookie, a mutation using the body-sourced token in X-CSRF-Token succeeds (200), and the same mutation without it still correctly 403s.
934 lines
37 KiB
TypeScript
934 lines
37 KiB
TypeScript
import {
|
||
createRootRouteWithContext,
|
||
createRoute,
|
||
createRouter,
|
||
Link,
|
||
Outlet,
|
||
redirect,
|
||
} from "@tanstack/react-router";
|
||
import { lazy, Suspense, useEffect, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
||
import {
|
||
can,
|
||
closeShift,
|
||
fetchShiftReport,
|
||
fetchVersion,
|
||
logout,
|
||
openShift,
|
||
setLanguagePref,
|
||
setThemePref,
|
||
setFontScalePref,
|
||
FONT_SCALE_MIN,
|
||
FONT_SCALE_MAX,
|
||
FONT_SCALE_STEP,
|
||
} from "./api.js";
|
||
import { qk, queryClient } from "./lib/query.js";
|
||
import { Modal } from "./ui/Modal.js";
|
||
import { Spinner } from "./ui/Spinner.js";
|
||
import { setLanguage } from "./lib/i18n/index.js";
|
||
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||
import { inTauri } from "./lib/origin.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 { TariffLab } from "./TariffLab.js";
|
||
import { SubscriptionManager } from "./SubscriptionManager.js";
|
||
import { SubscriptionPlansManager } from "./SubscriptionPlansManager.js";
|
||
import { SiteSettings } from "./SiteSettings.js";
|
||
import { UsersManager } from "./UsersManager.js";
|
||
import { RolesManager } from "./RolesManager.js";
|
||
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||
import { DrawerManager } from "./DrawerManager.js";
|
||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||
import { LogsViewer } from "./LogsViewer.js";
|
||
import { BackupSettings } from "./BackupSettings.js";
|
||
import { ValidateScreen } from "./ValidateScreen.js";
|
||
import { RecycleBin } from "./RecycleBin.js";
|
||
import { Profile } from "./Profile.js";
|
||
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
||
// initial bundle and only downloads when an admin opens /setup/reports.
|
||
const Reports = lazy(() => import("./Reports.js").then((m) => ({ default: m.Reports })));
|
||
|
||
// 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;
|
||
}
|
||
|
||
// Exported so a deep component (e.g. the booth pay modal) can read the signed-in user
|
||
// from route context without prop-threading through every layer.
|
||
export const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||
component: RootLayout,
|
||
});
|
||
|
||
function NavLink({ to, label }: { to: string; label: string }) {
|
||
return (
|
||
<Link
|
||
to={to}
|
||
className="px-2 py-1 text-[0.6875rem] uppercase tracking-wider text-term-muted rounded-term hover:text-term-text [&.active]:text-term-amber [&.active]:bg-term-panel-2"
|
||
>
|
||
{label}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<Link
|
||
to={to}
|
||
activeOptions={{ exact }}
|
||
className="border-b-2 border-transparent px-3 py-2 text-[0.75rem] uppercase tracking-wider text-term-muted hover:text-term-text [&.active]:border-term-amber [&.active]:text-term-amber"
|
||
>
|
||
{label}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
/** The running deploy's "<branch>-<short-sha>" (matches the Komodo Stack's TAG in
|
||
* komodo/resources.toml), gated the same as the "Park" tab (site:read) since it's the
|
||
* same kind of read-only app metadata. Renders nothing if the value isn't known (e.g. a
|
||
* local/dev build with no CI-supplied BUILD_VERSION) rather than showing an empty badge. */
|
||
function VersionBadge() {
|
||
const q = useQuery({ queryKey: ["version"], queryFn: fetchVersion, staleTime: Infinity });
|
||
const version = q.data?.buildVersion;
|
||
if (!version) return null;
|
||
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">{version}</span>;
|
||
}
|
||
|
||
/** The installed Tauri app's own "vX.Y.Z" (from tauri.conf.json, synced to the git tag by
|
||
* release.yml — see wiki/decisions/desktop-shell-tauri.md) — the client's version, distinct
|
||
* from VersionBadge's SERVER build. No-op / renders nothing in a browser (there's no Tauri
|
||
* API to call). Was invisible before this: an operator had no way to tell which desktop
|
||
* build was actually installed short of reading the update-available prompt. */
|
||
function DesktopVersionBadge() {
|
||
const [version, setVersion] = useState<string | null>(null);
|
||
useEffect(() => {
|
||
if (!inTauri()) return;
|
||
let cancelled = false;
|
||
void import("@tauri-apps/api/app").then(({ getVersion }) =>
|
||
getVersion().then((v) => {
|
||
if (!cancelled) setVersion(v);
|
||
}),
|
||
);
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
if (!version) return null;
|
||
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">app v{version}</span>;
|
||
}
|
||
|
||
/** Desktop-only "change which server this install talks to" control. No-op /
|
||
* renders nothing in a browser (the concept doesn't apply — same-origin).
|
||
* Simplest correct action: clear the saved backend URL and reload, which
|
||
* drops the app back to ConnectScreen (see App.tsx) to re-enter it — this
|
||
* mirrors clearing the session (logout → back to Login), not an inline
|
||
* editor, since repointing the app is a rare, deliberate admin action. */
|
||
function DesktopServerButton() {
|
||
const { t } = useTranslation();
|
||
const [confirming, setConfirming] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
if (!inTauri()) return null;
|
||
return (
|
||
<>
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost btn-sm ml-2"
|
||
onClick={() => setConfirming(true)}
|
||
>
|
||
{t("connect.changeServer")}
|
||
</button>
|
||
{confirming && (
|
||
<Modal open onClose={() => setConfirming(false)} title={t("connect.changeServer")} width="max-w-sm">
|
||
<div className="text-[0.8125rem]">
|
||
<p className="text-term-muted">{t("connect.changeServerConfirm")}</p>
|
||
<div className="mt-3 flex justify-end gap-2">
|
||
<button type="button" className="btn btn-sm" onClick={() => setConfirming(false)} disabled={busy}>
|
||
{t("subs.cancel")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm btn-danger"
|
||
disabled={busy}
|
||
onClick={async () => {
|
||
setBusy(true);
|
||
const { clearBackendUrl } = await import("./lib/backend-config.js");
|
||
await clearBackendUrl();
|
||
window.location.reload();
|
||
}}
|
||
>
|
||
{busy ? <Spinner /> : t("connect.changeServer")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
|
||
* tab's screen via <Outlet>. 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 (
|
||
<div className="">
|
||
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
|
||
{show("site:update") && <SetupTab to="/setup" label={t("nav.devices")} exact />}
|
||
{show("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />}
|
||
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
|
||
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
|
||
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
||
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
||
{show("site:read") && <VersionBadge />}
|
||
<DesktopVersionBadge />
|
||
<DesktopServerButton />
|
||
</nav>
|
||
<Outlet />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Subscriptions layout — a standalone top-level section (its own header nav entry),
|
||
* with tabs for the subscriber catalog and the plan catalog. Each tab is a gated
|
||
* child route; an operator with only subscription:read sees just the first tab.
|
||
* (The tariff lab moved to /setup/tariff/lab, 2026-07-05 — it tests the tariff, so
|
||
* it lives with the tariff.) */
|
||
function SubscriptionsLayout() {
|
||
const { user } = rootRoute.useRouteContext();
|
||
const { t } = useTranslation();
|
||
const show = (perm: Permission) => can(user, perm);
|
||
return (
|
||
<div className="">
|
||
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
|
||
{show("subscription:read") && <SetupTab to="/subscriptions" label={t("nav.subscriptions")} exact />}
|
||
{show("subscription:plan") && <SetupTab to="/subscriptions/plans" label={t("nav.plans")} />}
|
||
</nav>
|
||
<Outlet />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Tariff layout — the rate-card hub under Setup: the composer (index) and the
|
||
* pricing LAB as sub-tabs. One tariff:read gate on the parent covers both. */
|
||
function TariffLayout() {
|
||
const { t } = useTranslation();
|
||
return (
|
||
<div>
|
||
<nav className="mb-2 flex flex-wrap items-center gap-1 border-b border-term-border">
|
||
<SetupTab to="/setup/tariff" label={t("nav.tariff")} exact />
|
||
<SetupTab to="/setup/tariff/lab" label={t("nav.tariffLab")} />
|
||
</nav>
|
||
<Outlet />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
|
||
{(["sq", "en"] as const).map((l) => (
|
||
<button
|
||
key={l}
|
||
type="button"
|
||
onClick={() => pick(l)}
|
||
className={`rounded-term px-1.5 py-0.5 ${
|
||
active === l ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
|
||
}`}
|
||
>
|
||
{l}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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<Theme>(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 (
|
||
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
|
||
{(["dark", "light"] as const).map((th) => (
|
||
<button
|
||
key={th}
|
||
type="button"
|
||
onClick={() => pick(th)}
|
||
className={`rounded-term px-1.5 py-0.5 ${
|
||
active === th ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
|
||
}`}
|
||
>
|
||
{t(th === "dark" ? "common.themeDark" : "common.themeLight")}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Header font-size control: A−/value/A+ scaling the whole UI (root font-size). Persisted
|
||
* to the user profile like the theme, restored on next login. Local `active` state seeded
|
||
* from the prop (the router context doesn't re-render on setUser); App's effect keeps the
|
||
* DOM in sync with the persisted user on (re)login. */
|
||
function FontScaleToggle({ user, setUser }: { user: SessionUser; setUser: (u: SessionUser | null) => void }) {
|
||
const { t } = useTranslation();
|
||
const [active, setActive] = useState<number>(user.fontScale);
|
||
function step(delta: number) {
|
||
const next = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, active + delta));
|
||
if (next === active) return;
|
||
setActive(next);
|
||
applyFontScale(next); // instant UI
|
||
setUser({ ...user, fontScale: next });
|
||
void setFontScalePref(next).catch(() => {
|
||
/* non-fatal — the choice still applies this session */
|
||
});
|
||
}
|
||
const btn = "rounded-term px-1.5 py-0.5 text-term-muted hover:text-term-text disabled:opacity-40";
|
||
return (
|
||
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
|
||
<button type="button" className={btn} onClick={() => step(-FONT_SCALE_STEP)} disabled={active <= FONT_SCALE_MIN} title={t("common.fontSmaller")} aria-label={t("common.fontSmaller")}>
|
||
A−
|
||
</button>
|
||
<span className="min-w-[2.5rem] text-center text-term-muted" title={t("common.fontSize")}>{active}%</span>
|
||
<button type="button" className={btn} onClick={() => step(FONT_SCALE_STEP)} disabled={active >= FONT_SCALE_MAX} title={t("common.fontLarger")} aria-label={t("common.fontLarger")}>
|
||
A+
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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<string | null>(null);
|
||
// Closing a shift signs the Z-report and is irreversible, so the header button never
|
||
// closes directly (a stray click would end the shift) — it opens a confirm modal that
|
||
// shows the live X-report first. Opening a shift has no such risk → immediate.
|
||
const [confirmingClose, setConfirmingClose] = useState(false);
|
||
|
||
function onClick() {
|
||
if (isMine) {
|
||
setConfirmingClose(true);
|
||
} else {
|
||
void act("open");
|
||
}
|
||
}
|
||
|
||
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 (
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
disabled={busy || blockedByOther}
|
||
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
||
onClick={onClick}
|
||
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider disabled:opacity-60 ${tone}`}
|
||
>
|
||
{busy ? (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<Spinner /> {isMine ? t("shift.ending") : t("shift.opening")}
|
||
</span>
|
||
) : (
|
||
label
|
||
)}
|
||
</button>
|
||
{!isOpen && (
|
||
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
||
)}
|
||
{err && <span className="text-[0.625rem] text-term-red">{err}</span>}
|
||
{confirmingClose && (
|
||
<CloseShiftConfirm
|
||
busy={busy}
|
||
onCancel={() => setConfirmingClose(false)}
|
||
onConfirm={async () => {
|
||
await act("close");
|
||
setConfirmingClose(false);
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Confirm-before-close modal for the header shift button. Fetches the live X-report so
|
||
* the operator SEES their takings (split by source: tickets vs subscriptions) and the
|
||
* expected drawer before committing the irreversible Z-report. */
|
||
function CloseShiftConfirm({
|
||
busy,
|
||
onCancel,
|
||
onConfirm,
|
||
}: {
|
||
busy: boolean;
|
||
onCancel: () => void;
|
||
onConfirm: () => void;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const q = useQuery({ queryKey: ["shift", "xreport", "close-confirm"], queryFn: fetchShiftReport });
|
||
const x = q.data;
|
||
const cur = x?.currency ?? null;
|
||
const fmt = (m: number) => `${(m / 100).toLocaleString()} ${cur ?? ""}`.trim();
|
||
|
||
return (
|
||
<Modal open onClose={onCancel} title={t("shift.endShift")} width="max-w-md">
|
||
<div className="text-[0.8125rem] tabular-nums">
|
||
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
||
{!x ? (
|
||
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
|
||
) : (
|
||
<>
|
||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||
<ConfirmFigure label={t("shift.payments")} value={String(x.paymentCount)} />
|
||
<span />
|
||
{/* Split by source — the operator's ask: subscription money apart from tickets. */}
|
||
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
|
||
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
|
||
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
|
||
part is broken out below it; subscription SALES is not (it's the remainder). */}
|
||
<span />
|
||
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
|
||
</div>
|
||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
|
||
{CARD_PAYMENTS_ENABLED && <ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />}
|
||
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
|
||
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
|
||
<span />
|
||
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
|
||
</div>
|
||
</>
|
||
)}
|
||
<div className="mt-3 flex justify-end gap-2">
|
||
<button type="button" className="btn btn-sm" onClick={onCancel} disabled={busy}>
|
||
{t("subs.cancel")}
|
||
</button>
|
||
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
|
||
{busy ? (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<Spinner /> {t("shift.ending")}
|
||
</span>
|
||
) : (
|
||
t("shift.endShift")
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
||
return (
|
||
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
||
<span
|
||
className={`whitespace-nowrap text-[0.6875rem] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}
|
||
>
|
||
{label}
|
||
</span>
|
||
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
|
||
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RootLayout() {
|
||
const { user, setUser } = rootRoute.useRouteContext();
|
||
const { t } = useTranslation();
|
||
// 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);
|
||
// One app-wide WebSocket for the live feed (booth + any live widget) — but ONLY
|
||
// for roles the server would accept (routes/ws.ts gates on report:read). A
|
||
// merchant validator must not even attempt it: the 403'd upgrade would reconnect
|
||
// on backoff forever and spam the server log. Same rule for the widgets that feed
|
||
// off it (StatusDot) or make their own gated calls (ShiftButton → shift:read,
|
||
// DeviceFooter → device:read).
|
||
const canWatch = show("report:read");
|
||
useLiveFeed(canWatch);
|
||
|
||
return (
|
||
<div className="flex h-screen flex-col bg-term-bg text-term-text">
|
||
<header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2">
|
||
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
|
||
<nav className="flex items-center gap-1">
|
||
{show("session:read") && <NavLink to="/booth" label={t("nav.booth")} />}
|
||
{show("shift:read") && <NavLink to="/shifts" label={t("nav.shifts")} />}
|
||
{/* The merchant's (bar/lavazh) scan-and-validate screen. Their typical role
|
||
grants ONLY validation:create, so this is often their whole nav. */}
|
||
{show("validation:create") && <NavLink to="/validate" label={t("nav.validate")} />}
|
||
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
|
||
user can do either. See wiki/concepts/shift.md. */}
|
||
{(show("drawer:create") || show("drawer:review")) && (
|
||
<NavLink to="/drawer" label={t("nav.drawer")} />
|
||
)}
|
||
{/* Subscriptions — a standalone section (Abonimet / Planet / Lab tarife).
|
||
Shown if the user can reach ANY of its tabs. */}
|
||
{(show("subscription:read") || show("subscription:plan") || show("tariff:read")) && (
|
||
<NavLink to="/subscriptions" label={t("nav.subscriptions")} />
|
||
)}
|
||
{/* Reports — a standalone admin section (own header entry, route /reports). */}
|
||
{show("report:read") && <NavLink to="/reports" label={t("nav.reports")} />}
|
||
{/* One Setup entry — its tabs hold devices/tariff/site/users/roles/logs.
|
||
Shown if the user can reach ANY of those screens (an operator with only
|
||
shift:read still gets in, landing on Shifts). */}
|
||
{(show("site:update") ||
|
||
show("tariff:read") ||
|
||
show("site:read") ||
|
||
show("user:read") ||
|
||
show("role:read") ||
|
||
show("recyclebin:read") ||
|
||
show("backup:read") ||
|
||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||
</nav>
|
||
<div className="ml-auto flex items-center gap-3">
|
||
{user && show("shift:read") && <ShiftButton />}
|
||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||
{user && <FontScaleToggle user={user} setUser={setUser} />}
|
||
{canWatch && <StatusDot />}
|
||
{user && (
|
||
<Link
|
||
to="/profile"
|
||
title={t("nav.profile")}
|
||
className="text-[0.6875rem] text-term-muted hover:text-term-text [&.active]:text-term-amber"
|
||
>
|
||
{user.username} · {user.roleName}
|
||
</Link>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost btn-sm"
|
||
onClick={async () => {
|
||
await logout();
|
||
setUser(null);
|
||
}}
|
||
>
|
||
{t("common.logout")}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
<main className="min-h-0 flex-1 overflow-auto p-3">
|
||
<Outlet />
|
||
</main>
|
||
{/* Fixed device-status footer — relays, readers, cameras, printers. Its REST
|
||
seed needs device:read (and its live updates ride the report:read WS), so
|
||
it's hidden for roles without device visibility (e.g. merchant validators). */}
|
||
{user && show("device:read") && <DeviceFooter />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const indexRoute = createRoute({
|
||
getParentRoute: () => rootRoute,
|
||
path: "/",
|
||
beforeLoad: ({ context }) => {
|
||
// A merchant-only user (validation:create without the booth's session:read)
|
||
// lands on their scan-and-validate screen; everyone else on the booth.
|
||
if (can(context.user, "validation:create") && !can(context.user, "session:read")) {
|
||
throw redirect({ to: "/validate" });
|
||
}
|
||
throw redirect({ to: "/booth" });
|
||
},
|
||
});
|
||
|
||
const boothRoute = createRoute({
|
||
getParentRoute: () => rootRoute,
|
||
path: "/booth",
|
||
component: BoothScreen,
|
||
});
|
||
|
||
// The merchant (bar/lavazh) scan-and-validate screen — usually the ONLY page a
|
||
// merchant user's role can reach. The server enforces the program↔user binding on
|
||
// apply; this gate is defence in depth. See wiki/concepts/validation-discounts.md.
|
||
const validateRoute = createRoute({
|
||
getParentRoute: () => rootRoute,
|
||
path: "/validate",
|
||
beforeLoad: ({ context }) => requirePerm("validation:create")(context),
|
||
component: function ValidateRoute() {
|
||
const { user } = rootRoute.useRouteContext();
|
||
if (!user) return null;
|
||
return <ValidateScreen user={user} />;
|
||
},
|
||
});
|
||
|
||
// Back-compat redirects for paths that moved. Most config screens live under /setup;
|
||
// Subscriptions/Plans/Tariff-Lab were promoted OUT of /setup into the standalone
|
||
// /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing
|
||
// bookmarks/links don't 404. (No "/subscriptions" entry: that's now a REAL route.)
|
||
const legacyRedirects = (
|
||
[
|
||
["/tariff", "/setup/tariff"],
|
||
["/site", "/setup/site"],
|
||
["/users", "/setup/users"],
|
||
["/roles", "/setup/roles"],
|
||
["/shift", "/shifts"],
|
||
["/setup/subscriptions", "/subscriptions"],
|
||
["/setup/plans", "/subscriptions/plans"],
|
||
// The tariff lab bounced twice: /setup/tariff-lab → /subscriptions/tariff-lab
|
||
// (2026-06-21) → /setup/tariff/lab (2026-07-05, back with the tariff it tests).
|
||
["/setup/tariff-lab", "/setup/tariff/lab"],
|
||
["/subscriptions/tariff-lab", "/setup/tariff/lab"],
|
||
["/setup/shifts", "/shifts"],
|
||
["/setup/reports", "/reports"],
|
||
] as const
|
||
).map(([from, to]) =>
|
||
createRoute({
|
||
getParentRoute: () => rootRoute,
|
||
path: from,
|
||
beforeLoad: () => {
|
||
throw redirect({ to });
|
||
},
|
||
}),
|
||
);
|
||
|
||
// Admin reports/charts — a top-level section (own header nav entry), NOT a Setup tab.
|
||
// Gated by report:read. Lazy component (Recharts) in a Suspense so it stays out of the
|
||
// booth's initial bundle.
|
||
const reportsRoute = createRoute({
|
||
getParentRoute: () => rootRoute,
|
||
path: "/reports",
|
||
beforeLoad: ({ context }) => requirePerm("report:read")(context),
|
||
component: function ReportsRoute() {
|
||
return (
|
||
<Suspense fallback={<div className="p-3 text-term-muted">…</div>}>
|
||
<Reports />
|
||
</Suspense>
|
||
);
|
||
},
|
||
});
|
||
|
||
const shiftRoute = createRoute({
|
||
getParentRoute: () => rootRoute,
|
||
path: "/shifts",
|
||
component: function ShiftRoute() {
|
||
const { user } = rootRoute.useRouteContext();
|
||
// The shift hub: list (current/open shift on top + history) + per-shift activity log.
|
||
// The CURRENT shift's pane carries the actions (open/close, takings), each opening a
|
||
// modal. `canManage` = shift:create (start/end). Drawer cash movements moved to /drawer
|
||
// (2026-07-01).
|
||
return <ShiftsHistory user={user} canManage={can(user, "shift:create")} />;
|
||
},
|
||
});
|
||
|
||
const drawerRoute = createRoute({
|
||
getParentRoute: () => rootRoute,
|
||
path: "/drawer",
|
||
// Reachable by anyone who can record OR review; the component shows the right view per
|
||
// permission. Guard on the broader of the two (create) so a review-only admin still gets
|
||
// in — the redirect only fires if the user has NEITHER, which the nav already hides.
|
||
beforeLoad: ({ context }) => {
|
||
if (!can(context.user, "drawer:create") && !can(context.user, "drawer:review")) {
|
||
throw redirect({ to: "/booth" });
|
||
}
|
||
},
|
||
component: function DrawerRoute() {
|
||
const { user } = rootRoute.useRouteContext();
|
||
return (
|
||
<DrawerManager
|
||
canCreate={can(user, "drawer:create")}
|
||
canReview={can(user, "drawer:review")}
|
||
/>
|
||
);
|
||
},
|
||
});
|
||
|
||
/** 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 the standalone
|
||
// /shifts hub, which is no longer a Setup tab).
|
||
const SETUP_TABS: { to: string; perm: Permission }[] = [
|
||
{ to: "/setup", perm: "site:update" },
|
||
{ to: "/setup/tariff", perm: "tariff:read" },
|
||
{ to: "/setup/site", perm: "site:read" },
|
||
{ to: "/setup/users", perm: "user:read" },
|
||
{ to: "/setup/roles", perm: "role:read" },
|
||
{ to: "/setup/recycle-bin", perm: "recyclebin:read" },
|
||
{ to: "/shifts", perm: "shift:read" },
|
||
{ to: "/setup/logs", perm: "log:read" },
|
||
];
|
||
|
||
// /setup is a LAYOUT route (tab bar + <Outlet>); 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: () => <SetupWizard />,
|
||
});
|
||
const tariffRoute = createRoute({
|
||
getParentRoute: () => setupRoute,
|
||
path: "tariff",
|
||
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
||
component: TariffLayout,
|
||
});
|
||
const tariffComposerRoute = createRoute({
|
||
getParentRoute: () => tariffRoute,
|
||
path: "/",
|
||
component: () => <TariffComposer />,
|
||
});
|
||
// The tariff LAB — lives with the tariff it tests (moved from /subscriptions,
|
||
// 2026-07-05). The parent's tariff:read gate covers it.
|
||
const tariffLabRoute = createRoute({
|
||
getParentRoute: () => tariffRoute,
|
||
path: "lab",
|
||
component: () => <TariffLab />,
|
||
});
|
||
|
||
// --- /subscriptions — a standalone top-level section with its own tabs. The catalog
|
||
// (index), the plan catalog, and the tariff lab live here, not under /setup. ---
|
||
const subscriptionsRoute = createRoute({
|
||
getParentRoute: () => rootRoute,
|
||
path: "/subscriptions",
|
||
component: SubscriptionsLayout,
|
||
});
|
||
// Index tab = the subscriber catalog at /subscriptions exactly. A user lacking
|
||
// subscription:read is redirected to the first sub-tab they CAN see (or the booth).
|
||
const subscriptionsIndexRoute = createRoute({
|
||
getParentRoute: () => subscriptionsRoute,
|
||
path: "/",
|
||
beforeLoad: ({ context }) => {
|
||
if (can(context.user, "subscription:read")) return;
|
||
if (can(context.user, "subscription:plan")) throw redirect({ to: "/subscriptions/plans" });
|
||
throw redirect({ to: "/booth" });
|
||
},
|
||
component: function SubscriptionsRoute() {
|
||
const { user } = rootRoute.useRouteContext();
|
||
return <SubscriptionManager user={user} />;
|
||
},
|
||
});
|
||
const subscriptionPlansRoute = createRoute({
|
||
getParentRoute: () => subscriptionsRoute,
|
||
path: "plans",
|
||
beforeLoad: ({ context }) => requirePerm("subscription:plan")(context),
|
||
component: () => <SubscriptionPlansManager />,
|
||
});
|
||
const siteRoute = createRoute({
|
||
getParentRoute: () => setupRoute,
|
||
path: "site",
|
||
beforeLoad: ({ context }) => requirePerm("site:read")(context),
|
||
component: function SiteRoute() {
|
||
const { user } = rootRoute.useRouteContext();
|
||
return <SiteSettings canEdit={can(user, "site:update")} />;
|
||
},
|
||
});
|
||
const usersRoute = createRoute({
|
||
getParentRoute: () => setupRoute,
|
||
path: "users",
|
||
beforeLoad: ({ context }) => requirePerm("user:read")(context),
|
||
component: function UsersRoute() {
|
||
const { user } = rootRoute.useRouteContext();
|
||
return <UsersManager user={user} />;
|
||
},
|
||
});
|
||
const rolesRoute = createRoute({
|
||
getParentRoute: () => setupRoute,
|
||
path: "roles",
|
||
beforeLoad: ({ context }) => requirePerm("role:read")(context),
|
||
component: function RolesRoute() {
|
||
const { user } = rootRoute.useRouteContext();
|
||
return <RolesManager user={user} />;
|
||
},
|
||
});
|
||
// (Shift history lives at the standalone /shifts route — see shiftRoute. It was
|
||
// removed as a Setup tab; /setup/shifts and the old /shift both redirect there.)
|
||
|
||
// Recycle bin — restore/purge soft-deleted master data. Gated by recyclebin:read.
|
||
const recycleBinRoute = createRoute({
|
||
getParentRoute: () => setupRoute,
|
||
path: "recycle-bin",
|
||
beforeLoad: ({ context }) => requirePerm("recyclebin:read")(context),
|
||
component: function RecycleBinRoute() {
|
||
const { user } = rootRoute.useRouteContext();
|
||
return <RecycleBin user={user} />;
|
||
},
|
||
});
|
||
|
||
// 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,
|
||
});
|
||
|
||
// Encrypted DB backup — status + manual run. Gated by backup:read (run by backup:create).
|
||
const backupRoute = createRoute({
|
||
getParentRoute: () => setupRoute,
|
||
path: "backup",
|
||
beforeLoad: ({ context }) => requirePerm("backup:read")(context),
|
||
component: BackupSettings,
|
||
});
|
||
|
||
// My profile — self-service for ANY signed-in user (no permission gate). Edits only
|
||
// the caller's own name/email/password. See Profile.tsx and routes/auth.ts.
|
||
const profileRoute = createRoute({
|
||
getParentRoute: () => rootRoute,
|
||
path: "profile",
|
||
component: function ProfileRoute() {
|
||
const { user, setUser } = rootRoute.useRouteContext();
|
||
if (!user) return null;
|
||
return <Profile user={user} setUser={setUser} />;
|
||
},
|
||
});
|
||
|
||
const routeTree = rootRoute.addChildren([
|
||
indexRoute,
|
||
boothRoute,
|
||
validateRoute,
|
||
...legacyRedirects,
|
||
profileRoute,
|
||
shiftRoute,
|
||
drawerRoute,
|
||
reportsRoute,
|
||
subscriptionsRoute.addChildren([
|
||
subscriptionsIndexRoute,
|
||
subscriptionPlansRoute,
|
||
]),
|
||
setupRoute.addChildren([
|
||
setupDevicesRoute,
|
||
tariffRoute.addChildren([tariffComposerRoute, tariffLabRoute]),
|
||
siteRoute,
|
||
usersRoute,
|
||
rolesRoute,
|
||
recycleBinRoute,
|
||
logsRoute,
|
||
backupRoute,
|
||
]),
|
||
]);
|
||
|
||
export const router = createRouter({
|
||
routeTree,
|
||
context: { user: null, setUser: () => {} },
|
||
defaultPreload: "intent",
|
||
});
|
||
|
||
declare module "@tanstack/react-router" {
|
||
interface Register {
|
||
router: typeof router;
|
||
}
|
||
}
|