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