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
+183
View File
@@ -0,0 +1,183 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { fetchShifts, type ShiftSummary, type SessionUser } from "./api.js";
import { formatMoney, formatDuration } from "./lib/format.js";
// Completed shift history. Scope is enforced SERVER-SIDE by permission: an operator
// gets only their own shifts; an admin (shift:cash) gets all + a date/operator
// filter. The screen mirrors that — it shows the filter only when the server
// reports scope:"all". Each row is one signed shift_z_report; expanding it shows the
// drawer reconciliation. See wiki/concepts/shift.md.
/** Local date + time (history spans days, so not just time-of-day). */
function fmtDateTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function money(minor: number, currency: string | null): string {
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
}
export function ShiftsHistory({ user }: { user: SessionUser | null }) {
const { t } = useTranslation();
// Admin filter inputs (only sent when the server grants the "all" scope; for an
// operator the server ignores them anyway).
const [operator, setOperator] = useState("");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
// The applied filter (separate from the inputs, so typing doesn't refetch).
const [applied, setApplied] = useState<{ operator?: string; from?: string; to?: string }>({});
const q = useQuery({
queryKey: ["shifts", applied],
queryFn: () => fetchShifts(applied),
});
const isAdmin = q.data?.scope === "all";
const shifts = q.data?.shifts ?? [];
function apply() {
setApplied({
operator: operator.trim() || undefined,
// A date input gives yyyy-mm-dd; widen `to` to the end of that day.
from: from ? new Date(`${from}T00:00:00`).toISOString() : undefined,
to: to ? new Date(`${to}T23:59:59`).toISOString() : undefined,
});
}
function clear() {
setOperator("");
setFrom("");
setTo("");
setApplied({});
}
return (
<div className="mx-auto max-w-4xl">
<div className="mb-3 flex items-center justify-between">
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
</h1>
</div>
{/* Admin-only filter: by operator + a date window over the shift start. */}
{isAdmin && (
<div className="card mb-3 flex flex-wrap items-end gap-3 p-3">
<div className="field">
<span className="label">{t("shifts.operator")}</span>
<input
className="input w-44"
value={operator}
onChange={(e) => setOperator(e.target.value)}
placeholder={t("shifts.allOperators")}
/>
</div>
<div className="field">
<span className="label">{t("shifts.filterFrom")}</span>
<input type="date" className="input w-44" value={from} onChange={(e) => setFrom(e.target.value)} />
</div>
<div className="field">
<span className="label">{t("shifts.filterTo")}</span>
<input type="date" className="input w-44" value={to} onChange={(e) => setTo(e.target.value)} />
</div>
<button type="button" className="btn btn-primary btn-sm" onClick={apply}>
{t("shifts.apply")}
</button>
<button type="button" className="btn btn-sm" onClick={clear}>
{t("shifts.clear")}
</button>
</div>
)}
{q.isError && (
<div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">
{t("shifts.loadFailed")}
</div>
)}
<div className="overflow-hidden rounded-term border border-term-border">
<table className="w-full text-[12px] tabular-nums">
<thead className="bg-term-panel-2 text-[11px] uppercase tracking-wider text-term-muted">
<tr>
{isAdmin && <th className="px-3 py-1.5 text-left">{t("shifts.operator")}</th>}
<th className="px-3 py-1.5 text-left">{t("shifts.started")}</th>
<th className="px-3 py-1.5 text-left">{t("shifts.ended")}</th>
<th className="px-3 py-1.5 text-right">{t("shifts.payments")}</th>
<th className="px-3 py-1.5 text-right">{t("shifts.cash")}</th>
<th className="px-3 py-1.5 text-right">{t("shifts.card")}</th>
<th className="px-3 py-1.5 text-right">{t("shifts.expectedDrawer")}</th>
</tr>
</thead>
<tbody>
{shifts.map((s) => (
<ShiftRow key={s.id} s={s} showOperator={isAdmin} colSpan={isAdmin ? 7 : 6} />
))}
{!q.isLoading && shifts.length === 0 && (
<tr>
<td colSpan={isAdmin ? 7 : 6} className="px-3 py-3 text-term-muted">
{t("shifts.none")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function ShiftRow({ s, showOperator, colSpan }: { s: ShiftSummary; showOperator: boolean; colSpan: number }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const cur = s.currency;
return (
<>
<tr
className="cursor-pointer border-t border-term-border hover:bg-term-panel-2"
onClick={() => setOpen((o) => !o)}
>
{showOperator && <td className="px-3 py-1.5 text-term-text">{s.operator}</td>}
<td className="px-3 py-1.5">{fmtDateTime(s.startedAt)}</td>
<td className="px-3 py-1.5">
{fmtDateTime(s.endedAt)}
<span className="ml-2 text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
</td>
<td className="px-3 py-1.5 text-right">{s.paymentCount}</td>
<td className="px-3 py-1.5 text-right text-term-green">{money(s.cashTotalMinor, cur)}</td>
<td className="px-3 py-1.5 text-right text-term-cyan">{money(s.cardTotalMinor, cur)}</td>
<td className="px-3 py-1.5 text-right font-semibold">{money(s.expectedDrawerMinor, cur)}</td>
</tr>
{open && (
<tr className="border-t border-term-border/50 bg-term-bg">
<td colSpan={colSpan} className="px-3 py-2">
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("shifts.drawerSection")}</div>
<div className="mt-1 grid grid-cols-2 gap-x-8 gap-y-0.5 sm:grid-cols-4">
<Figure label={t("shifts.openingFloat")} value={money(s.openingFloatMinor, cur)} />
<Figure label={t("shifts.cashTaken")} value={money(s.cashTotalMinor, cur)} />
<Figure label={t("shifts.cashAdded")} value={money(s.cashAddedMinor, cur)} />
<Figure label={t("shifts.cashRemoved")} value={money(s.cashRemovedMinor, cur)} />
</div>
</td>
</tr>
)}
</>
);
}
function Figure({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between gap-2">
<span className="text-term-muted">{label}</span>
<span className="text-term-text">{value}</span>
</div>
);
}