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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user