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:
+172
-26
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user