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
+75
View File
@@ -38,6 +38,25 @@ export class NoShiftOpenError extends Error {
}
}
/** A COMPLETED shift, reconstructed from its signed `shift_z_report` (which carries
* all the figures in its payload). This is the unit of the shift-history feature.
* `id` is the z_report's ledger id (stable, for the UI list key / future deep-link). */
export interface ShiftSummary {
readonly id: string;
readonly index: number;
readonly operator: string;
readonly startedAt: string;
readonly endedAt: string;
readonly cashTotalMinor: number;
readonly cardTotalMinor: number;
readonly currency: string | null;
readonly paymentCount: number;
readonly openingFloatMinor: number;
readonly cashAddedMinor: number;
readonly cashRemovedMinor: number;
readonly expectedDrawerMinor: number;
}
export interface ShiftReport {
readonly operator: string;
readonly startedAt: string;
@@ -115,6 +134,62 @@ export class ShiftService {
return last && last.type === "shift_open" ? last : null;
}
/**
* COMPLETED shift history, newest first. Each closed shift is one signed
* `shift_z_report` whose payload already holds every figure, so this is a simple
* read of those rows (no re-summing). Optional filters:
* - operator: only this operator's shifts (the `identity` on the z_report).
* - from/to: ISO timestamps; keep shifts whose START falls in [from, to].
* The open shift (no z_report yet) is intentionally excluded — it's not a
* completed accountability period. Use `currentOpenShift()` for the live one.
*/
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "shift_z_report"))
.orderBy(ledgerEvents.index)
.all();
const out: ShiftSummary[] = [];
for (const r of rows) {
const pl = (r.payload ?? {}) as LedgerPayload & {
operator?: string;
startedAt?: string;
endedAt?: string;
cashTotalMinor?: number;
cardTotalMinor?: number;
paymentCount?: number;
openingFloatMinor?: number;
cashAddedMinor?: number;
cashRemovedMinor?: number;
expectedDrawerMinor?: number;
};
const operator = pl.operator ?? r.identity ?? "?";
const startedAt = pl.startedAt ?? r.occurredAt;
if (opts.operator && operator !== opts.operator) continue;
if (opts.from && startedAt < opts.from) continue;
if (opts.to && startedAt > opts.to) continue;
out.push({
id: r.id,
index: r.index,
operator,
startedAt,
endedAt: pl.endedAt ?? r.occurredAt,
cashTotalMinor: pl.cashTotalMinor ?? 0,
cardTotalMinor: pl.cardTotalMinor ?? 0,
currency: pl.currency ?? null,
paymentCount: pl.paymentCount ?? 0,
openingFloatMinor: pl.openingFloatMinor ?? 0,
cashAddedMinor: pl.cashAddedMinor ?? 0,
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
expectedDrawerMinor: pl.expectedDrawerMinor ?? 0,
});
}
// Newest first for the history list.
return out.reverse();
}
/** Require an open shift for the booth money path; returns it or throws. */
requireOpenShift() {
const open = this.currentOpenShift();