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
@@ -0,0 +1,239 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { CARWASH_PAY_AT, CARWASH_PROGRAM_ID, CARWASH_VALIDATION_MODES, type CarWashPayAt } from "@parking/shared";
import { fetchValidationPrograms, type ValidationProgramView } from "../../api.js";
import { StationForm, defaultProgram } from "../../ValidationSetup.js";
import { fetchCarwashSettings, saveCarwashSettings, type CarwashSettingsView } from "./api.js";
// Setup → Car wash: the master data (vehicle categories, services, the category ×
// service price matrix) and the parking SPONSORSHIP a wash grants — the latter is a
// validation program (id "carwash"), composed with the same editor the merchant
// programs use. See wiki/decisions/venue-modules.md ("v1 answers").
type Item = { id?: string; name: string; active: boolean };
const fromMinor = (v: number | undefined): string => (v == null ? "" : (v / 100).toFixed(2).replace(/\.00$/, ""));
const toMinor = (s: string): number | null => {
const v = s.trim();
if (v === "") return null;
const n = Number(v);
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : null;
};
function ListEditor({
title,
items,
onChange,
addLabel,
}: {
title: string;
items: Item[];
onChange: (items: Item[]) => void;
addLabel: string;
}) {
const { t } = useTranslation();
return (
<div className="grid gap-1.5">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{title}</div>
{items.map((it, i) => (
<div key={it.id ?? `new-${i}`} className="flex items-center gap-2">
<input
className="input flex-1"
value={it.name}
onChange={(e) => onChange(items.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))}
/>
<label className="flex items-center gap-1 text-[0.75rem] text-term-muted">
<input
type="checkbox"
className="accent-term-amber"
checked={it.active}
onChange={(e) => onChange(items.map((x, j) => (j === i ? { ...x, active: e.target.checked } : x)))}
/>
{t("wash.active")}
</label>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => onChange(items.filter((_, j) => j !== i))}>
✕
</button>
</div>
))}
<button type="button" className="btn btn-sm self-start" onClick={() => onChange([...items, { name: "", active: true }])}>
+ {addLabel}
</button>
</div>
);
}
export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
const { t } = useTranslation();
const [settings, setSettings] = useState<CarwashSettingsView | null>(null);
const [categories, setCategories] = useState<Item[]>([]);
const [services, setServices] = useState<Item[]>([]);
/** Price inputs keyed "categoryId|serviceId" (major units as typed). New rows have no
* id yet, so the matrix keys use the row INDEX until saved. */
const [prices, setPrices] = useState<Record<string, string>>({});
/** Where the money is taken — a SITE policy (user, 2026-09-05), not a per-order radio. */
const [payAt, setPayAt] = useState<CarWashPayAt>("booth");
const [msg, setMsg] = useState<string | null>(null);
const [program, setProgram] = useState<ValidationProgramView | null>(null);
function load() {
fetchCarwashSettings()
.then((s) => {
setSettings(s);
setCategories(s.categories.map((c) => ({ id: c.id, name: c.name, active: c.active })));
setServices(s.services.map((x) => ({ id: x.id, name: x.name, active: x.active })));
const p: Record<string, string> = {};
for (const r of s.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
setPrices(p);
setPayAt(s.payAt);
})
.catch((e) => setMsg((e as Error).message));
fetchValidationPrograms()
.then((r) => {
const existing = r.programs.find((p) => p.id === CARWASH_PROGRAM_ID);
setProgram(existing ?? { id: CARWASH_PROGRAM_ID, ...defaultProgram(CARWASH_PROGRAM_ID, t("wash.sponsorshipLabel")) });
})
.catch(() => {});
}
useEffect(load, []); // eslint-disable-line react-hooks/exhaustive-deps
const keyOf = (c: Item, ci: number, s: Item, si: number) => `${c.id ?? `#${ci}`}|${s.id ?? `#${si}`}`;
async function save() {
setMsg(null);
try {
const listBody = {
categories: categories.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active })),
services: services.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })),
};
// New rows have no id until the server assigns one, and the price matrix is keyed
// by ids — so save the lists first, map each new row to the id that came back (the
// server returns rows in the order sent), then save the prices in a second call.
// One button, two requests; the user just sees "Saved."
let cats = categories;
let svcs = services;
if (categories.some((c) => !c.id) || services.some((s) => !s.id)) {
// Keep only the prices whose rows survive this save (a removed row's prices
// would be refused as unknown ids).
const keepC = new Set(categories.map((c) => c.id).filter(Boolean));
const keepS = new Set(services.map((s) => s.id).filter(Boolean));
const first = await saveCarwashSettings({
...listBody,
prices: (settings?.prices ?? []).filter((p) => keepC.has(p.categoryId) && keepS.has(p.serviceId)),
});
cats = categories.map((c, i) => ({ ...c, id: c.id ?? first.categories[i]?.id }));
svcs = services.map((s, i) => ({ ...s, id: s.id ?? first.services[i]?.id }));
}
const priceRows: { categoryId: string; serviceId: string; priceMinor: number }[] = [];
categories.forEach((c, ci) =>
services.forEach((s, si) => {
const v = toMinor(prices[keyOf(c, ci, s, si)] ?? "");
const cid = cats[ci]?.id;
const sid = svcs[si]?.id;
if (v != null && cid && sid) priceRows.push({ categoryId: cid, serviceId: sid, priceMinor: v });
}),
);
const saved = await saveCarwashSettings({
categories: cats.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active })),
services: svcs.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })),
prices: priceRows,
payAt,
});
setSettings(saved);
setPayAt(saved.payAt);
setCategories(saved.categories.map((c) => ({ id: c.id, name: c.name, active: c.active })));
setServices(saved.services.map((x) => ({ id: x.id, name: x.name, active: x.active })));
const p: Record<string, string> = {};
for (const r of saved.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
setPrices(p);
setMsg(t("wash.saved"));
} catch (e) {
setMsg((e as Error).message);
}
}
const currency = settings?.currency ?? "";
return (
<div className="mt-6 flex flex-wrap items-start gap-6">
<section className="card w-full max-w-2xl p-4">
<div className="grid gap-4">
<ListEditor title={t("wash.categories")} items={categories} onChange={setCategories} addLabel={t("wash.addCategory")} />
<ListEditor title={t("wash.services")} items={services} onChange={setServices} addLabel={t("wash.addService")} />
<div>
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">
{t("wash.prices")} {currency && <span className="normal-case tracking-normal">({currency})</span>}
</div>
<span className="hint">{t("wash.pricesHint")}</span>
{categories.length > 0 && services.length > 0 && (
<div className="mt-2 overflow-x-auto">
<table className="text-[0.75rem]">
<thead>
<tr>
<th className="py-1 pr-3 text-left text-term-muted"></th>
{services.map((s, si) => (
<th key={s.id ?? `#${si}`} className="py-1 pr-3 text-left">{s.name || "…"}</th>
))}
</tr>
</thead>
<tbody>
{categories.map((c, ci) => (
<tr key={c.id ?? `#${ci}`}>
<td className="py-1 pr-3 font-semibold">{c.name || "…"}</td>
{services.map((s, si) => {
const k = keyOf(c, ci, s, si);
return (
<td key={k} className="py-1 pr-3">
<input
className="input w-24 text-right tabular-nums"
value={prices[k] ?? ""}
disabled={!canEdit}
onChange={(e) => setPrices((p) => ({ ...p, [k]: e.target.value }))}
placeholder="—"
/>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="field">
<span className="label">{t("wash.payAt")}</span>
<div className="flex gap-4 text-[0.75rem]">
{CARWASH_PAY_AT.map((v) => (
<label key={v} className="flex items-center gap-1.5">
<input type="radio" name="carwash-payAt" className="accent-term-amber" checked={payAt === v} disabled={!canEdit} onChange={() => setPayAt(v)} />
{t(v === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}
</label>
))}
</div>
<span className="hint">{t(payAt === "booth" ? "wash.payAtBoothHint" : "wash.payAtBayHint")}</span>
</div>
{canEdit && (
<div className="flex items-center gap-3">
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("wash.save")}</button>
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
</div>
)}
</div>
</section>
{canEdit && program && (
<section className="card w-full max-w-md p-4">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.sponsorship")}</div>
<span className="hint">{t("wash.sponsorshipHint")}</span>
<div className="mt-2">
<StationForm program={program} onSaved={setProgram} hideUsers modes={CARWASH_VALIDATION_MODES} />
</div>
</section>
)}
</div>
);
}