feat(carwash): Car Wash v1 + per-till shifts + site-level pay-at + till access by module permission

Car Wash — the pilot venue module (wiki/decisions/venue-modules.md):
- Master data (categories × services price matrix) at /setup/carwash; the desk at /wash
  (ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void;
  Finished list). Orders freeze names + price; their life is signed (carwash_order,
  carwash_payment). Migration 0027.
- Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed
  config_change on a flip) — no per-order radio; a stale client is refused (409).
- Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as
  chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash
  signs the $0 parking payment so the exit reader releases the car.
- "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash
  price off the fee (floored at 0), resolved at done and anchored at the order's intake
  (the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for
  the wash. Long durations render y/d/h/m.

Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills):
- TillId booth|carwash; every money event names its till (absent = booth, so the chain
  re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports,
  vouchers, carry-forward. A bay payment needs the carwash shift.
- Working a till needs that till's module permission (manifest tillPermission; 403
  till_forbidden); /api/shift/tills lists only the role's tills.
- Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every
  open shift with till badges + filter; drawer hub switches tills.

Modules: landing per module (index route resolves booth → module landing → shifts →
profile); guards bounce to "/", /booth needs session:read.

Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at
policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky
backup test under the parallel run).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
2026-09-05 13:23:09 +02:00
parent 23d6379be8
commit a9ccf9e20c
46 changed files with 3966 additions and 510 deletions
+86 -37
View File
@@ -4,13 +4,14 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
closeShift,
fetchEvents,
fetchShift,
fetchShiftReport,
fetchShiftTills,
fetchShifts,
openShift,
type ShiftReport,
type ShiftSummary,
type SessionUser,
type TillId,
} from "./api.js";
import { formatMoney, formatDuration, formatDateTime, formatRelativeDateTime } from "./lib/format.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
@@ -24,7 +25,9 @@ import type { LedgerEvent } from "@parking/shared";
// selected shift's signed activity log (every ledger event in its window). The current
// shift's pane carries the shift ACTIONS (End shift / drawer voucher / takings-so-far),
// each opening a modal. Scope is enforced SERVER-SIDE: an operator sees only their own;
// an admin (shift:cash) sees all. See wiki/concepts/shift.md.
// an admin (shift:cash) sees all. TILLS: a shift belongs to a till (booth / wash desk);
// every open shift (one per till) lists on top, cards carry a till badge when the site
// has more than one, and the list can be filtered by till. See wiki/concepts/shift.md.
function money(minor: number, currency: string | null): string {
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
@@ -47,28 +50,35 @@ function presetRange(p: Preset): { from: string; to: string } | null {
return { from: iso(from), to: iso(now) };
}
/** The CURRENT (open) shift, synthesized from the X-report so it lists alongside closed
* shifts. `id` is a sentinel; `open` marks it for the badge + the action pane. null when
* no shift is open (or not visible to the requester). */
function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; isMine: boolean; refetch: () => void } {
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift });
const report = useQuery({
queryKey: ["shift", "xreport"],
queryFn: fetchShiftReport,
enabled: status.data?.open != null,
type CurrentShift = ShiftSummary & { open: true; isMine: boolean };
/** The CURRENT (open) shifts — one per till at most — each synthesized from its till's
* X-report so it lists alongside closed shifts. `id` is a sentinel per till; `open`
* marks it for the badge + the action pane. Also returns every till the site has, so
* the hub can offer "start shift" per till and show badges only when there are two. */
function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; refetch: () => void } {
const status = useQuery({ queryKey: ["shift", "tills"], queryFn: fetchShiftTills });
const openTills = (status.data?.tills ?? []).filter((t) => t.open != null);
// One X-report per open till (the key carries the till list so a newly opened
// shift refetches).
const reports = useQuery({
queryKey: ["shift", "xreport", "hub", openTills.map((t) => t.till).join(",")],
queryFn: async () => Promise.all(openTills.map((t) => fetchShiftReport(t.till))),
enabled: openTills.length > 0,
});
const refetch = () => {
void status.refetch();
void report.refetch();
void reports.refetch();
};
if (!status.data?.open || !report.data) return { current: null, isMine: status.data?.isMine ?? false, refetch };
const x = report.data;
return {
isMine: status.data.isMine,
refetch,
current: {
id: "__current__",
const tills = status.data?.tills.map((t) => t.till) ?? ["booth"];
const current: CurrentShift[] = [];
openTills.forEach((t, i) => {
const x = reports.data?.[i];
if (!x) return;
current.push({
id: `__current__${t.till}`,
index: Number.MAX_SAFE_INTEGER,
till: x.till,
operator: x.operator,
startedAt: x.startedAt,
endedAt: x.asOf,
@@ -85,8 +95,10 @@ function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; i
cashRemovedMinor: x.cashRemovedMinor,
expectedDrawerMinor: x.expectedDrawerMinor,
open: true,
},
};
isMine: t.isMine,
});
});
return { current, tills, refetch };
}
export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) {
@@ -96,14 +108,17 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
const [customFrom, setCustomFrom] = useState("");
const [customTo, setCustomTo] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [tillFilter, setTillFilter] = useState<TillId | "">("");
const { current, isMine, refetch: refetchCurrent } = useCurrentShift();
const { current, tills, refetch: refetchCurrent } = useCurrentShifts();
const multiTill = tills.length > 1;
const range = preset === "custom" ? { from: customFrom, to: customTo } : presetRange(preset);
const applied = {
operator: operator.trim() || undefined,
from: range?.from ? new Date(`${range.from}T00:00:00`).toISOString() : undefined,
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
till: tillFilter || undefined,
};
// keepPreviousData: every filter change makes a NEW query key; without it the
@@ -118,16 +133,23 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
const closed = q.data?.shifts ?? [];
const operators = q.data?.operators ?? [];
// The current/open shift sits at the TOP of the list (when present + visible to me).
const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed;
// The current/open shifts sit at the TOP of the list (those visible to me: mine, or
// all for an admin), honouring the till filter.
const visibleCurrent = current.filter((c) => (c.isMine || isAdmin) && (!tillFilter || c.till === tillFilter));
const list: (ShiftSummary & { open?: boolean; isMine?: boolean })[] = [...visibleCurrent, ...closed];
const selected = list.find((s) => s.id === selectedId) ?? list[0] ?? null;
const currentIds = visibleCurrent.map((c) => c.id).join(",");
// Default the selection to the current shift (if any), else the newest closed one.
useEffect(() => {
if (list.length === 0) setSelectedId(null);
else if (!list.some((s) => s.id === selectedId)) setSelectedId(list[0]!.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.data, current?.id]);
}, [q.data, currentIds]);
// Tills with no open shift → offer "start" for each (gated on shift:create).
const openOn = new Set(current.map((c) => c.till));
const startable = tills.filter((x) => !openOn.has(x));
function refreshAll() {
void q.refetch();
@@ -145,9 +167,13 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
</h1>
{/* No shift open → the only action is to start one (gated on shift:create). */}
{canManage && !current && (
<StartShiftButton onDone={refreshAll} />
{/* A till with no open shift → the action is to start one (gated on shift:create). */}
{canManage && startable.length > 0 && (
<span className="flex flex-wrap items-center gap-2">
{startable.map((x) => (
<StartShiftButton key={x} till={x} named={multiTill} onDone={refreshAll} />
))}
</span>
)}
</div>
@@ -175,6 +201,16 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
</div>
</>
)}
{multiTill && (
<div className="field">
<select className="input w-40" value={tillFilter} onChange={(e) => setTillFilter(e.target.value as TillId | "")}>
<option value="">{t("till.all")}</option>
{tills.map((x) => (
<option key={x} value={x}>{t(`till.${x}Long`)}</option>
))}
</select>
</div>
)}
{isAdmin && (
<div className="field">
{/* <span className="label">{t("shifts.operator")}</span> */}
@@ -202,7 +238,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
<p className="rounded-term border border-term-border px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.none")}</p>
)}
{list.map((s) => (
<ShiftCard key={s.id} s={s} showOperator={isAdmin} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
<ShiftCard key={s.id} s={s} showOperator={isAdmin} showTill={multiTill} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
))}
</div>
@@ -211,8 +247,9 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
<ShiftActivityLog
shift={selected}
isCurrent={!!selected.open}
isMine={isMine}
isMine={!!selected.isMine}
showOperator={isAdmin}
showTill={multiTill}
canManage={canManage}
onChanged={refreshAll}
/>
@@ -225,7 +262,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
);
}
function StartShiftButton({ onDone }: { onDone: () => void }) {
function StartShiftButton({ till, named, onDone }: { till: TillId; named: boolean; onDone: () => void }) {
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
@@ -233,7 +270,7 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
setBusy(true);
setErr(null);
try {
await openShift();
await openShift(till);
onDone();
} catch (e) {
setErr((e as Error).message);
@@ -249,6 +286,8 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
<span className="inline-flex items-center gap-1.5">
<Spinner /> {t("shift.starting")}
</span>
) : named ? (
t("shift.tillOpen", { till: t(`till.${till}`) })
) : (
t("shift.startShift")
)}
@@ -257,7 +296,13 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
);
}
function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSummary; showOperator: boolean; open: boolean; selected: boolean; onClick: () => void }) {
/** Which drawer a shift reconciled — shown only when the site has more than one. */
function TillBadge({ till }: { till: TillId }) {
const { t } = useTranslation();
return <span className="rounded border border-term-cyan/60 px-1 text-[0.625rem] uppercase tracking-wider text-term-cyan">{t(`till.${till}`)}</span>;
}
function ShiftCard({ s, showOperator, showTill, open, selected, onClick }: { s: ShiftSummary; showOperator: boolean; showTill: boolean; open: boolean; selected: boolean; onClick: () => void }) {
const { t } = useTranslation();
const cur = s.currency;
const when = (iso: string) => formatRelativeDateTime(iso, t);
@@ -270,6 +315,7 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2 font-semibold text-term-text">
{open && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
{showTill && <TillBadge till={s.till} />}
{showOperator ? s.operator : when(s.startedAt)}
</span>
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
@@ -290,6 +336,7 @@ function ShiftActivityLog({
isCurrent,
isMine,
showOperator,
showTill,
canManage,
onChanged,
}: {
@@ -297,6 +344,7 @@ function ShiftActivityLog({
isCurrent: boolean;
isMine: boolean;
showOperator: boolean;
showTill: boolean;
canManage: boolean;
onChanged: () => void;
}) {
@@ -322,6 +370,7 @@ function ShiftActivityLog({
<div className="flex flex-wrap items-center justify-between gap-2 text-[0.75rem]">
<span className="flex items-center gap-2 font-semibold text-term-text">
{isCurrent && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
{showTill && <TillBadge till={shift.till} />}
{showOperator && `${shift.operator} · `}
{formatRelativeDateTime(shift.startedAt, t)}
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
@@ -358,7 +407,7 @@ function ShiftActivityLog({
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />}
{modal === "takings" && <TakingsModal till={shift.till} onClose={() => setModal(null)} />}
</div>
);
}
@@ -376,7 +425,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
setBusy(true);
setErr(null);
try {
setReport(await closeShift());
setReport(await closeShift(shift.till));
onDone();
} catch (e) {
setErr((e as Error).message);
@@ -447,9 +496,9 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
);
}
function TakingsModal({ onClose }: { onClose: () => void }) {
function TakingsModal({ till, onClose }: { till: TillId; onClose: () => void }) {
const { t } = useTranslation();
const q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport });
const q = useQuery({ queryKey: ["shift", "xreport", "modal", till], queryFn: () => fetchShiftReport(till) });
const x = q.data;
return (
<Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md">