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
+46 -24
View File
@@ -13,18 +13,21 @@ import {
type DrawerMovement,
type MovementStatus,
type ShiftSummary,
type TillId,
} from "./api.js";
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
import { shiftKey } from "./lib/use-shift.js";
import { Panel } from "./ui/Panel.js";
import type { LedgerEvent } from "@parking/shared";
import { tillOf, type LedgerEvent } from "@parking/shared";
// The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen
// answers "what's in the till and why": the CURRENT drawer balance with the open
// shift's running breakdown (float + takings + vouchers = expected), TODAY's cash
// activity (every cash payment and voucher, live), the movement record/review flow
// (unchanged), and the closed-shift drawer history. All figures come from the signed
// chain — the drawer is a single site-wide till that carries across shifts. See
// wiki/concepts/shift.md.
// chain. TILLS (2026-09-05): there is one drawer PER TILL (booth, wash desk); the hub
// shows one till at a time — a switch appears when the site has more than one — and
// every panel below is scoped to it. See wiki/concepts/shift.md "Tills".
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
@@ -53,6 +56,11 @@ function StatusBadge({ status }: { status: MovementStatus }) {
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient();
const [till, setTill] = useState<TillId>("booth");
// Which tills exist here (the booth + effective money-taking modules') — from the
// booth's status read, which every till answer carries.
const status = useQuery({ queryKey: shiftKey("booth"), queryFn: () => fetchShift("booth") });
const tills = status.data?.tills ?? ["booth"];
const refresh = () => {
void qc.invalidateQueries({ queryKey: ["drawer"] });
// A voucher moves the open shift's added/removed figures too (the X-report).
@@ -61,17 +69,28 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
return (
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3 lg:overflow-hidden">
{/* Till switch — only when there is more than one drawer to look at. */}
{tills.length > 1 && (
<div className="flex shrink-0 items-center gap-1.5">
{tills.map((x) => (
<button key={x} type="button" className={`btn btn-sm ${till === x ? "btn-primary" : ""}`} onClick={() => setTill(x)}>
{t(`till.${x}Long`)}
</button>
))}
</div>
)}
{/* Row 1: the till NOW + the record form. */}
<div className="grid shrink-0 gap-3 lg:grid-cols-[1.3fr_1fr]">
<StatePanel />
{canCreate && <RecordPanel onDone={refresh} />}
<StatePanel till={till} />
{canCreate && <RecordPanel till={till} onDone={refresh} />}
</div>
{/* Row 2: today's cash feed · the movement review queue · closed shifts. */}
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-3">
<TodayPanel />
<MovementsPanel canReview={canReview} onChanged={refresh} />
<ShiftHistoryPanel />
<TodayPanel till={till} />
<MovementsPanel till={till} canReview={canReview} onChanged={refresh} />
<ShiftHistoryPanel till={till} />
</div>
</div>
);
@@ -81,13 +100,13 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
// Balance from the chain + the open shift's running X-report breakdown, so the big
// number is always explainable: float + cash takings + in − out = expected = balance.
function StatePanel() {
function StatePanel({ till }: { till: TillId }) {
const { t } = useTranslation();
const balance = useQuery({ queryKey: ["drawer", "balance"], queryFn: fetchDrawerBalance, refetchInterval: 10_000 });
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift });
const balance = useQuery({ queryKey: ["drawer", "balance", till], queryFn: () => fetchDrawerBalance(till), refetchInterval: 10_000 });
const status = useQuery({ queryKey: shiftKey(till), queryFn: () => fetchShift(till) });
const report = useQuery({
queryKey: ["shift", "xreport"],
queryFn: fetchShiftReport,
queryKey: ["shift", "xreport", till],
queryFn: () => fetchShiftReport(till),
enabled: status.data?.open != null,
refetchInterval: 10_000,
});
@@ -149,7 +168,7 @@ function StatePanel() {
// Every drawer-touching event since local midnight: cash payments (the current
// shift's incomings, live) + vouchers. Card payments never enter the till.
function TodayPanel() {
function TodayPanel({ till }: { till: TillId }) {
const { t } = useTranslation();
const q = useQuery({
queryKey: ["drawer", "today"],
@@ -157,9 +176,12 @@ function TodayPanel() {
refetchInterval: 15_000,
});
// This till's drawer-touching events only (a bay payment is wash-till money; a
// parking payment is booth money — tillOf() is the one shared rule).
const rows = (q.data?.events ?? []).filter((e) => {
if (tillOf(e.payload) !== till) return false;
if (e.type === "cash_in" || e.type === "cash_out") return true;
if (e.type !== "payment") return false;
if (e.type !== "payment" && e.type !== "carwash_payment") return false;
return (e.payload as { tender?: string } | null)?.tender !== "card";
});
@@ -171,7 +193,7 @@ function TodayPanel() {
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string };
const amt = pl.amountMinor ?? 0;
if (pl.currency) cur = pl.currency;
if (e.type === "payment") {
if (e.type === "payment" || e.type === "carwash_payment") {
cashIn += amt;
payments++;
} else {
@@ -225,7 +247,7 @@ function TodayRow({ e }: { e: LedgerEvent }) {
const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt);
const time = formatClock(e.occurredAt);
const label =
e.type === "payment"
e.type === "payment" || e.type === "carwash_payment"
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
: `${e.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}${pl.voucherNo ? ` ${pl.voucherNo}` : ""}`;
return (
@@ -243,13 +265,13 @@ function TodayRow({ e }: { e: LedgerEvent }) {
// --- Movements (record + review) — the pre-redesign feature, unchanged ------
function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChanged: () => void }) {
function MovementsPanel({ till, canReview, onChanged }: { till: TillId; canReview: boolean; onChanged: () => void }) {
const { t } = useTranslation();
// Reviewers can filter the list (the pending queue); operators always see their own, all.
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
const q = useQuery({
queryKey: ["drawer", "movements", canReview ? statusFilter : ""],
queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined),
queryKey: ["drawer", "movements", canReview ? statusFilter : "", till],
queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined, till),
});
const movements = q.data?.movements ?? [];
const pendingCount = movements.filter((m) => m.status === "pending").length;
@@ -316,9 +338,9 @@ function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChange
// --- Closed shifts, drawer-focused -------------------------------------------
// Scope follows /api/shifts: operators see their own, admins all.
function ShiftHistoryPanel() {
function ShiftHistoryPanel({ till }: { till: TillId }) {
const { t } = useTranslation();
const q = useQuery({ queryKey: ["shifts", "drawer-history"], queryFn: () => fetchShifts() });
const q = useQuery({ queryKey: ["shifts", "drawer-history", till], queryFn: () => fetchShifts({ till }) });
const shifts = (q.data?.shifts ?? []).slice(0, 50);
const showOperator = q.data?.scope === "all";
@@ -377,14 +399,14 @@ function ShiftDrawerCard({ s, showOperator }: { s: ShiftSummary; showOperator: b
// --- Record form (unchanged from the pre-redesign feature) ------------------
function RecordPanel({ onDone }: { onDone: () => void }) {
function RecordPanel({ till, onDone }: { till: TillId; onDone: () => void }) {
const { t } = useTranslation();
const [amount, setAmount] = useState("");
const [reason, setReason] = useState("");
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
const record = useMutation({
mutationFn: (type: "cash_in" | "cash_out") =>
recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim() }),
recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim(), till }),
onSuccess: (r) => {
setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) });
setAmount("");