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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }) });
|
||||
}
|
||||
@@ -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];
|
||||
},
|
||||
};
|
||||
@@ -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];
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user