feat: tabbed setup, user metadata, light theme, scoped shift history

Consolidate the config screens under a single /setup hub with permission-
gated tabs (Devices/Tariff/Subscriptions/Site/Users/Roles/Shifts), collapsing
the top nav to Booth·Shift·Setup; old top-level paths redirect.

Users: add optional profile metadata (full name, phone, email, address) on
create/edit. Theme: a light palette saved to the user's profile (users.theme),
toggled in the header beside the language switch and applied on load like the
language preference. Both ride on a single additive migration (0008).

Shift history: a new GET /api/shifts folds the signed shift_z_report chain into
completed shifts, SCOPED server-side — operators see only their own; holders of
shift:cash see all with an operator + date-range filter. Surfaced as the Shifts
tab; an operator cannot read another operator's takings (param spoofing is
ignored).

These three features share the router, api client and i18n catalogs, so they
land together. Verified live: theme persists across reload, metadata round-
trips to the DB, and shift scoping holds (operator self-only, admin all+filter).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-19 10:09:18 +02:00
parent 8444bf34c3
commit 040c0ff4ca
16 changed files with 1062 additions and 99 deletions
+172 -26
View File
@@ -9,10 +9,11 @@ import {
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import type { Lang, Permission, SessionUser } from "./api.js";
import { can, closeShift, logout, openShift, setLanguagePref } from "./api.js";
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";
@@ -25,6 +26,7 @@ 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";
// 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
@@ -51,6 +53,43 @@ function NavLink({ to, label }: { to: string; label: string }) {
);
}
/** 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-[12px] uppercase tracking-wider text-term-muted hover:text-term-text [&.active]:border-term-amber [&.active]:text-term-amber"
>
{label}
</Link>
);
}
/** 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="mx-auto max-w-4xl">
<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("subscription:read") && <SetupTab to="/setup/subscriptions" label={t("nav.subscriptions")} />}
{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("shift:read") && <SetupTab to="/setup/shifts" label={t("nav.shifts")} />}
</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({
@@ -88,6 +127,45 @@ function LanguageToggle({
);
}
/** 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();
async function pick(theme: Theme) {
if (theme === user.theme) return;
applyTheme(theme); // instant UI
setUser({ ...user, theme });
try {
await setThemePref(theme); // persist
} catch {
/* non-fatal — the choice still applies this session */
}
}
return (
<div className="flex items-center gap-0.5 text-[10px] 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 ${
user.theme === 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 shift control — the site-wide single-open shift expressed as one button:
* - no shift open → "Open shift" (enabled; opens this operator's shift)
@@ -167,23 +245,28 @@ function RootLayout() {
<nav className="flex items-center gap-1">
<NavLink to="/booth" label={t("nav.booth")} />
<NavLink to="/shift" label={t("nav.shift")} />
{show("site:update") && <NavLink to="/setup" label={t("nav.setup")} />}
{show("tariff:read") && <NavLink to="/tariff" label={t("nav.tariff")} />}
{show("subscription:read") && <NavLink to="/subscriptions" label={t("nav.subscriptions")} />}
{show("site:read") && <NavLink to="/site" label={t("nav.site")} />}
{show("user:read") && <NavLink to="/users" label={t("nav.users")} />}
{show("role:read") && <NavLink to="/roles" label={t("nav.roles")} />}
{/* One Setup entry — its tabs hold devices/tariff/subscriptions/site/users/
roles/shifts. 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("subscription:read") ||
show("site:read") ||
show("user:read") ||
show("role:read") ||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
</nav>
<div className="ml-auto flex items-center gap-3">
{user && <ShiftButton />}
{user && <LanguageToggle user={user} setUser={setUser} />}
{user && <ThemeToggle user={user} setUser={setUser} />}
<StatusDot />
<span className="text-[11px] text-term-muted">
{user?.username} · {user?.roleName}
</span>
<button
type="button"
className="rounded-term border border-term-border px-2 py-0.5 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text"
className="btn btn-ghost btn-sm"
onClick={async () => {
await logout();
setUser(null);
@@ -216,6 +299,26 @@ const boothRoute = createRoute({
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",
@@ -235,27 +338,55 @@ function requirePerm(perm: Permission) {
};
}
// 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" },
];
// /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",
beforeLoad: ({ context }) => requirePerm("site:update")(context),
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: () => rootRoute,
path: "/tariff",
getParentRoute: () => setupRoute,
path: "tariff",
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
component: () => <TariffComposer />,
});
const subscriptionsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/subscriptions",
getParentRoute: () => setupRoute,
path: "subscriptions",
beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
component: () => <SubscriptionManager />,
});
const siteRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/site",
getParentRoute: () => setupRoute,
path: "site",
beforeLoad: ({ context }) => requirePerm("site:read")(context),
component: function SiteRoute() {
const { user } = rootRoute.useRouteContext();
@@ -263,8 +394,8 @@ const siteRoute = createRoute({
},
});
const usersRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/users",
getParentRoute: () => setupRoute,
path: "users",
beforeLoad: ({ context }) => requirePerm("user:read")(context),
component: function UsersRoute() {
const { user } = rootRoute.useRouteContext();
@@ -272,25 +403,40 @@ const usersRoute = createRoute({
},
});
const rolesRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/roles",
getParentRoute: () => setupRoute,
path: "roles",
beforeLoad: ({ context }) => requirePerm("role:read")(context),
component: function RolesRoute() {
const { user } = rootRoute.useRouteContext();
return <RolesManager user={user} />;
},
});
// 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 <ShiftsHistory user={user} />;
},
});
const routeTree = rootRoute.addChildren([
indexRoute,
boothRoute,
...legacyRedirects,
shiftRoute,
setupRoute,
tariffRoute,
subscriptionsRoute,
siteRoute,
usersRoute,
rolesRoute,
setupRoute.addChildren([
setupDevicesRoute,
tariffRoute,
subscriptionsRoute,
siteRoute,
usersRoute,
rolesRoute,
shiftsHistoryRoute,
]),
]);
export const router = createRouter({