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