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>
);
}
+354
View File
@@ -0,0 +1,354 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Tender } from "@parking/shared";
import { formatMoney } from "../../lib/format.js";
import { useShift } from "../../lib/use-shift.js";
import { ShiftButton } from "../../ShiftControl.js";
import {
createCarwashOrder,
fetchCarwashOrders,
fetchCarwashSettings,
lookupCarwashTicket,
markCarwashDone,
payCarwashAtBay,
voidCarwashOrder,
type CarwashOrderView,
type CarwashSettingsView,
type CarwashTicketLookup,
} from "./api.js";
// The wash desk (/wash): intake a wash against a parking ticket (category × service →
// price, where the money is taken), then work the queue — a plain list of open orders,
// oldest first: Done / Pay at bay / Void. Bay money lands on the WASH TILL: the desk
// carries that till's own shift control, and the pay buttons are gated on the wash
// operator's shift (the booth's shift does not cover the bay — the two drawers
// reconcile separately). See wiki/decisions/venue-modules.md + shift.md "Tills".
const QK = ["carwash", "orders"] as const;
function timeOf(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
export function WashDesk() {
const { t } = useTranslation();
const qc = useQueryClient();
const [settings, setSettings] = useState<CarwashSettingsView | null>(null);
const [ticket, setTicket] = useState("");
const [lookup, setLookup] = useState<CarwashTicketLookup | null>(null);
const [categoryId, setCategoryId] = useState("");
const [serviceId, setServiceId] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [voiding, setVoiding] = useState<{ id: string; reason: string } | null>(null);
useEffect(() => {
fetchCarwashSettings().then(setSettings).catch((e) => setMsg((e as Error).message));
}, []);
const orders = useQuery({
queryKey: QK,
queryFn: () => fetchCarwashOrders("open"),
refetchInterval: 5000,
});
// Finished washes (done + paid, or voided) — the most recent ones, newest first, so
// the desk can answer "did we wash that car?" without leaving the screen.
const finished = useQuery({
queryKey: [...QK, "recent"],
queryFn: () => fetchCarwashOrders("recent"),
refetchInterval: 15000,
select: (r) => r.orders.filter((o) => o.closed).slice(0, 50),
});
const categories = useMemo(() => (settings?.categories ?? []).filter((c) => c.active), [settings]);
const services = useMemo(() => (settings?.services ?? []).filter((s) => s.active), [settings]);
const price = useMemo(
() => settings?.prices.find((p) => p.categoryId === categoryId && p.serviceId === serviceId) ?? null,
[settings, categoryId, serviceId],
);
const currency = settings?.currency ?? lookup?.currency ?? null;
async function doLookup(e: React.FormEvent) {
e.preventDefault();
setMsg(null);
if (!ticket.trim()) return;
try {
setLookup(await lookupCarwashTicket(ticket));
} catch (err) {
setMsg((err as Error).message);
}
}
const invalidate = () => qc.invalidateQueries({ queryKey: QK }); // also matches [...QK, "recent"]
const create = useMutation({
mutationFn: () =>
createCarwashOrder({ identity: lookup!.identity, categoryId, serviceId }),
onSuccess: () => {
setMsg(t("wash.created"));
setLookup(null);
setTicket("");
void invalidate();
},
onError: (e) => setMsg((e as Error).message),
});
const done = useMutation({
mutationFn: (id: string) => markCarwashDone(id),
onSuccess: () => void invalidate(),
onError: (e) => setMsg((e as Error).message),
});
const pay = useMutation({
mutationFn: ({ id, tender }: { id: string; tender: Tender }) => payCarwashAtBay(id, tender),
onSuccess: () => void invalidate(),
onError: (e) => setMsg((e as Error).message),
});
const voidIt = useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) => voidCarwashOrder(id, reason),
onSuccess: () => {
setVoiding(null);
void invalidate();
},
onError: (e) => setMsg((e as Error).message),
});
const canCreate =
lookup?.found && lookup.open && !!categoryId && !!serviceId && price != null && !create.isPending;
// The wash till's shift: money at the bay is only takeable while MY wash shift is open.
const washShift = useShift("carwash");
const canTakeMoney = washShift.isMine;
return (
<div className="mt-6 flex flex-wrap items-start gap-6">
<section className="flex w-full flex-wrap items-center gap-3 rounded-term border border-term-border bg-term-panel-2 px-4 py-2">
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.tillTitle")}</span>
<ShiftButton till="carwash" />
{washShift.status && (
<span className="text-[0.75rem] tabular-nums text-term-muted">
{t("wash.drawerNow")}{" "}
<span className="font-semibold text-term-text">
{formatMoney(washShift.status.drawerMinor, washShift.status.currency ?? currency ?? "")}
</span>
</span>
)}
<span className="basis-full text-[0.6875rem] text-term-muted">
{washShift.blockedByOther ? t("wash.tillOtherHint", { operator: washShift.heldBy ?? "?" }) : t("wash.tillHint")}
</span>
</section>
<section className="card w-full max-w-md p-4">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.intake")}</div>
<form onSubmit={doLookup} className="mt-3 flex gap-2">
<input
className="input flex-1"
value={ticket}
onChange={(e) => {
setTicket(e.target.value);
setLookup(null);
}}
placeholder={t("wash.ticketPh")}
autoFocus
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
<button type="submit" className="btn btn-sm" disabled={!ticket.trim()}>
{t("wash.lookup")}
</button>
</form>
{lookup && !lookup.found && <p className="mt-2 text-[0.75rem] text-term-red">{t("wash.notFound")}</p>}
{lookup?.found && !lookup.open && <p className="mt-2 text-[0.75rem] text-term-red">{t("wash.closed")}</p>}
{lookup?.found && lookup.open && (
<div className="mt-3 grid gap-3">
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
<div className="flex justify-between">
<span className="text-term-muted">{t("wash.ticket")}</span>
<span className="font-mono">{lookup.identity}</span>
</div>
<div className="flex justify-between">
<span className="text-term-muted">{t("wash.plate")}</span>
<span className="font-mono">{lookup.plate ?? "—"}</span>
</div>
{lookup.enteredAt && (
<div className="flex justify-between">
<span className="text-term-muted">{t("wash.enteredAt")}</span>
<span className="tabular-nums">{timeOf(lookup.enteredAt)}</span>
</div>
)}
{lookup.orders.filter((o) => !o.closed).length > 0 && (
<div className="mt-1 text-term-amber">{t("wash.alreadyOpen")}</div>
)}
</div>
<div className="field">
<span className="label">{t("wash.category")}</span>
<select className="select" value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
<option value="">—</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="field">
<span className="label">{t("wash.service")}</span>
<select className="select" value={serviceId} onChange={(e) => setServiceId(e.target.value)}>
<option value="">—</option>
{services.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div className="flex items-center justify-between text-[0.8125rem]">
<span className="text-term-muted">{t("wash.price")}</span>
<span className="font-semibold tabular-nums">
{price && currency ? formatMoney(price.priceMinor, currency) : categoryId && serviceId ? t("wash.noPrice") : "—"}
</span>
</div>
{/* Where the money is taken is the SITE's setting (Setup → Car wash), shown
here so the operator knows what this order will do — never chosen per order. */}
<div className="flex items-center justify-between text-[0.75rem]">
<span className="text-term-muted">{t("wash.payAt")}</span>
<span>{settings ? t(settings.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay") : "—"}</span>
</div>
<button type="button" className="btn btn-primary btn-sm" disabled={!canCreate} onClick={() => create.mutate()}>
{t("wash.create")}
</button>
</div>
)}
{msg && <p className="mt-3 text-[0.75rem] text-term-muted">{msg}</p>}
</section>
<section className="card w-full max-w-3xl p-4">
<div className="flex items-center justify-between">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.queue")}</div>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => void orders.refetch()}>↻</button>
</div>
{(orders.data?.orders ?? []).length === 0 ? (
<p className="mt-3 text-[0.75rem] text-term-muted">{t("wash.empty")}</p>
) : (
<div className="mt-2 overflow-x-auto">
<table className="w-full text-[0.75rem]">
<thead className="text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
<tr>
<th className="py-1 pr-3">{t("wash.time")}</th>
<th className="py-1 pr-3">{t("wash.ticket")}</th>
<th className="py-1 pr-3">{t("wash.plate")}</th>
<th className="py-1 pr-3">{t("wash.what")}</th>
<th className="py-1 pr-3 text-right">{t("wash.price")}</th>
<th className="py-1 pr-3">{t("wash.payAt")}</th>
<th className="py-1 pr-3">{t("wash.status")}</th>
<th className="py-1"></th>
</tr>
</thead>
<tbody>
{(orders.data?.orders ?? []).map((o: CarwashOrderView) => (
<tr key={o.id} className="border-t border-term-border/50">
<td className="py-1.5 pr-3 tabular-nums">{timeOf(o.createdAt)}</td>
<td className="py-1.5 pr-3 font-mono">{o.identity}</td>
<td className="py-1.5 pr-3 font-mono">{o.plate ?? "—"}</td>
<td className="py-1.5 pr-3">{o.categoryName} · {o.serviceName}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{formatMoney(o.priceMinor, o.currency)}</td>
<td className="py-1.5 pr-3">{t(o.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}</td>
<td className="py-1.5 pr-3">
<span className={o.status === "done" ? "text-term-green" : "text-term-amber"}>
{t(o.status === "done" ? "wash.statusDone" : "wash.statusOpen")}
</span>
<span className="text-term-muted"> · </span>
<span className={o.paidAt ? "text-term-green" : "text-term-muted"}>
{t(o.paidAt ? "wash.paid" : "wash.unpaid")}
</span>
</td>
<td className="py-1.5">
<div className="flex flex-wrap justify-end gap-1">
{o.status === "open" && (
<button type="button" className="btn btn-sm btn-primary" disabled={done.isPending} onClick={() => done.mutate(o.id)}>
{t("wash.done")}
</button>
)}
{o.payAt === "bay" && !o.paidAt && (
<>
<button type="button" className="btn btn-sm" disabled={pay.isPending || !canTakeMoney} title={canTakeMoney ? undefined : t("wash.tillHint")} onClick={() => pay.mutate({ id: o.id, tender: "cash" })}>
{t("wash.payCash")}
</button>
<button type="button" className="btn btn-sm" disabled={pay.isPending || !canTakeMoney} title={canTakeMoney ? undefined : t("wash.tillHint")} onClick={() => pay.mutate({ id: o.id, tender: "card" })}>
{t("wash.payCard")}
</button>
</>
)}
{!o.paidAt && (
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setVoiding({ id: o.id, reason: "" })}>
{t("wash.void")}
</button>
)}
</div>
{voiding?.id === o.id && (
<div className="mt-1 flex gap-1">
<input
className="input flex-1"
value={voiding.reason}
placeholder={t("wash.voidReason")}
onChange={(e) => setVoiding({ id: o.id, reason: e.target.value })}
/>
<button type="button" className="btn btn-sm btn-danger" disabled={voidIt.isPending} onClick={() => voidIt.mutate(voiding)}>
{t("wash.void")}
</button>
<button type="button" className="btn btn-sm" onClick={() => setVoiding(null)}>
{t("subs.cancel")}
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="mt-6 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.finished")}</div>
{(finished.data ?? []).length === 0 ? (
<p className="mt-2 text-[0.75rem] text-term-muted">{t("wash.finishedEmpty")}</p>
) : (
<div className="mt-2 overflow-x-auto">
<table className="w-full text-[0.75rem]">
<thead className="text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
<tr>
<th className="py-1 pr-3">{t("wash.time")}</th>
<th className="py-1 pr-3">{t("wash.ticket")}</th>
<th className="py-1 pr-3">{t("wash.plate")}</th>
<th className="py-1 pr-3">{t("wash.what")}</th>
<th className="py-1 pr-3 text-right">{t("wash.price")}</th>
<th className="py-1 pr-3">{t("wash.payAt")}</th>
<th className="py-1 pr-3">{t("wash.status")}</th>
<th className="py-1">{t("wash.by")}</th>
</tr>
</thead>
<tbody className="text-term-muted">
{(finished.data ?? []).map((o: CarwashOrderView) => (
<tr key={o.id} className="border-t border-term-border/50">
<td className="py-1.5 pr-3 tabular-nums">{timeOf(o.doneAt ?? o.paidAt ?? o.createdAt)}</td>
<td className="py-1.5 pr-3 font-mono">{o.identity}</td>
<td className="py-1.5 pr-3 font-mono">{o.plate ?? "—"}</td>
<td className="py-1.5 pr-3">{o.categoryName} · {o.serviceName}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{formatMoney(o.priceMinor, o.currency)}</td>
<td className="py-1.5 pr-3">{t(o.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}</td>
<td className="py-1.5 pr-3">
{o.status === "void" ? (
<span className="text-term-red">{t("wash.voided")}{o.voidReason ? ` · ${o.voidReason}` : ""}</span>
) : (
<span className="text-term-green">
{t("wash.statusDone")} · {t("wash.paid")}{o.tender ? ` (${t(o.tender === "card" ? "wash.card" : "wash.cash")})` : ""}
</span>
)}
</td>
<td className="py-1.5">{o.status === "void" ? o.voidBy ?? "" : o.paidBy ?? o.doneBy ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import type { CarWashPayAt, CarwashOrderView, CarwashSettingsView, Tender } from "@parking/shared";
import { apiFetch } from "../../api.js";
// The Car Wash module's API client — module-local so apps/web/src/api.ts (the core
// client) never learns about wash endpoints. Shapes come from @parking/shared.
export type { CarWashPayAt, CarwashOrderView, CarwashSettingsView };
export interface CarwashTicketLookup {
identity: string;
found: boolean;
open: boolean;
subscription: boolean;
plate: string | null;
enteredAt: string | null;
currency: string | null;
orders: CarwashOrderView[];
}
export interface CarwashSettingsBody {
categories?: { id?: string; name: string; active?: boolean }[];
services?: { id?: string; name: string; active?: boolean }[];
prices?: { categoryId: string; serviceId: string; priceMinor: number }[];
/** Where wash money is taken at this site (site-level; the desk no longer asks). */
payAt?: CarWashPayAt;
}
export function fetchCarwashSettings(): Promise<CarwashSettingsView> {
return apiFetch("/api/carwash/settings");
}
export function saveCarwashSettings(body: CarwashSettingsBody): Promise<CarwashSettingsView> {
return apiFetch("/api/carwash/settings", { method: "PUT", body: JSON.stringify(body) });
}
export function lookupCarwashTicket(identity: string): Promise<CarwashTicketLookup> {
return apiFetch(`/api/carwash/session/${encodeURIComponent(identity.trim())}`);
}
export function fetchCarwashOrders(scope: "open" | "recent" = "open"): Promise<{ orders: CarwashOrderView[] }> {
return apiFetch(`/api/carwash/orders?scope=${scope}`);
}
export function createCarwashOrder(body: {
identity: string;
categoryId: string;
serviceId: string;
}): Promise<CarwashOrderView> {
return apiFetch("/api/carwash/orders", { method: "POST", body: JSON.stringify(body) });
}
export function markCarwashDone(id: string): Promise<CarwashOrderView> {
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/done`, { method: "POST" });
}
export function payCarwashAtBay(id: string, tender: Tender): Promise<CarwashOrderView> {
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/pay`, { method: "POST", body: JSON.stringify({ tender }) });
}
export function voidCarwashOrder(id: string, reason: string): Promise<CarwashOrderView> {
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/void`, { method: "POST", body: JSON.stringify({ reason }) });
}
+49
View File
@@ -0,0 +1,49 @@
import { createRoute, redirect, useRouteContext } from "@tanstack/react-router";
import type { AnyRoute } from "@tanstack/react-router";
import { can } from "../../api.js";
import { moduleOn, type RootRoute, type WebModule } from "../../lib/modules.js";
import type { RouterContext } from "../../router.js";
import { CarWashSetup } from "./CarWashSetup.js";
import { WashDesk } from "./WashDesk.js";
// Car Wash — the pilot venue module, web side (wiki/decisions/venue-modules.md).
// Two screens: the wash desk (/wash, carwash:read) and Setup → Car wash
// (/setup/carwash, site:read; editing needs site:update). Both gate on the module
// being effective at this site AND the permission; the server enforces the same.
function gate(perm: string) {
return ({ context }: { context: unknown }) => {
const ctx = context as RouterContext;
// Bounce to the landing resolver, never straight to the booth (a wash-only role
// has no booth to land on).
if (!moduleOn(ctx.user, "carwash") || !can(ctx.user, perm)) throw redirect({ to: "/" });
};
}
export const carwashModule: WebModule = {
id: "carwash",
nav: [{ to: "/wash", labelKey: "nav.wash", perm: "carwash:read" }],
landing: { to: "/wash", labelKey: "nav.wash", perm: "carwash:read" },
routes(root: RootRoute) {
const washRoute = createRoute({
getParentRoute: () => root,
path: "/wash",
beforeLoad: gate("carwash:read"),
component: WashDesk,
});
return [washRoute];
},
setupNav: [{ to: "/setup/carwash", labelKey: "nav.carwash", perm: "site:read" }],
setupRoutes(setup: AnyRoute) {
const setupCarwashRoute = createRoute({
getParentRoute: () => setup,
path: "/carwash",
beforeLoad: gate("site:read"),
component: function CarWashSetupRoute() {
const { user } = useRouteContext({ strict: false }) as RouterContext;
return <CarWashSetup canEdit={can(user, "site:update")} />;
},
});
return [setupCarwashRoute];
},
};
+2 -1
View File
@@ -1,4 +1,5 @@
import type { WebModule } from "../lib/modules.js";
import { carwashModule } from "./carwash/index.js";
import { validationModule } from "./validation/index.js";
// The web-side module registry, in display order. Adding a module = its folder here
@@ -6,4 +7,4 @@ import { validationModule } from "./validation/index.js";
// into the nav and the route tree and never names a module's screens itself.
// `parking` has no folder yet — its screens are still declared directly in
// router.tsx; they move behind this seam subsystem by subsystem.
export const WEB_MODULES: readonly WebModule[] = [validationModule];
export const WEB_MODULES: readonly WebModule[] = [validationModule, carwashModule];
+2 -1
View File
@@ -13,6 +13,7 @@ import { ValidateScreen } from "../../ValidateScreen.js";
export const validationModule: WebModule = {
id: "validation",
nav: [{ to: "/validate", labelKey: "nav.validate", perm: "validation:create" }],
landing: { to: "/validate", labelKey: "nav.validate", perm: "validation:create" },
routes(root: RootRoute) {
const validateRoute = createRoute({
getParentRoute: () => root,
@@ -20,7 +21,7 @@ export const validationModule: WebModule = {
beforeLoad: ({ context }) => {
const ctx = context as RouterContext;
if (!moduleOn(ctx.user, "validation") || !can(ctx.user, "validation:create")) {
throw redirect({ to: "/booth" });
throw redirect({ to: "/" });
}
},
component: function ValidateRoute() {