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
+58 -3
View File
@@ -45,6 +45,7 @@ export class ApiError extends Error {
// --- Auth -----------------------------------------------------------------
export type Lang = "sq" | "en";
export type Theme = "dark" | "light";
/** A `resource:action` permission string (the server is the source of truth for
* the full grid; the role composer fetches it via /api/roles). */
export type Permission = string;
@@ -57,6 +58,10 @@ export interface SessionUser {
permissions: Permission[];
/** Preferred UI language (loaded from the server on login). */
language: Lang;
/** Preferred UI theme (loaded from the server on login). */
theme: Theme;
/** Optional display name (profile metadata); null if unset. */
fullName: string | null;
}
/** Does this session grant the permission? Central authz check for the SPA. */
@@ -80,6 +85,11 @@ export function setLanguagePref(language: Lang): Promise<{ language: Lang }> {
return apiFetch("/api/auth/language", { method: "PUT", body: JSON.stringify({ language }) });
}
/** Persist the current user's UI theme preference (restored on next login). */
export function setThemePref(theme: Theme): Promise<{ theme: Theme }> {
return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) });
}
/** Returns the current user, or null if not authenticated. */
export async function fetchMe(): Promise<SessionUser | null> {
try {
@@ -92,7 +102,14 @@ export async function fetchMe(): Promise<SessionUser | null> {
// --- User & role management (RBAC) ----------------------------------------
export interface ManagedUser {
/** Optional profile metadata on a managed user (all nullable). */
export interface UserProfile {
fullName: string | null;
phone: string | null;
email: string | null;
address: string | null;
}
export interface ManagedUser extends UserProfile {
id: string;
username: string;
roleId: string;
@@ -111,10 +128,15 @@ export interface ManagedRole {
export function fetchUsers(): Promise<{ users: ManagedUser[] }> {
return apiFetch("/api/users");
}
export function createUser(body: { username: string; password: string; roleId: string }): Promise<ManagedUser> {
export function createUser(
body: { username: string; password: string; roleId: string } & Partial<UserProfile>,
): Promise<ManagedUser> {
return apiFetch("/api/users", { method: "POST", body: JSON.stringify(body) });
}
export function updateUser(id: string, body: { username?: string; roleId?: string }): Promise<ManagedUser> {
export function updateUser(
id: string,
body: { username?: string; roleId?: string } & Partial<UserProfile>,
): Promise<ManagedUser> {
return apiFetch(`/api/users/${id}`, { method: "PUT", body: JSON.stringify(body) });
}
export function resetUserPassword(id: string, password: string): Promise<{ ok: boolean }> {
@@ -517,6 +539,39 @@ export function recordCashMovement(
});
}
/** A completed shift (reconstructed from its signed Z-report). */
export interface ShiftSummary {
id: string;
index: number;
operator: string;
startedAt: string;
endedAt: string;
cashTotalMinor: number;
cardTotalMinor: number;
currency: string | null;
paymentCount: number;
openingFloatMinor: number;
cashAddedMinor: number;
cashRemovedMinor: number;
expectedDrawerMinor: number;
}
/** Completed shift history. The server scopes by permission: operators get their
* own shifts only (filter args ignored); admins (shift:cash) get all, optionally
* filtered by operator + a from/to window over the shift start. `scope` echoes
* which the server applied, so the UI can show/hide the filter. */
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
shifts: ShiftSummary[];
scope: "all" | "self";
}> {
const qs = new URLSearchParams();
if (params.operator) qs.set("operator", params.operator);
if (params.from) qs.set("from", params.from);
if (params.to) qs.set("to", params.to);
const q = qs.toString();
return apiFetch(`/api/shifts${q ? `?${q}` : ""}`);
}
// --- Site config / occupancy ----------------------------------------------
export interface Occupancy {