import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { CARWASH_PAY_AT, CARWASH_PROGRAM_ID, CARWASH_VALIDATION_MODES, VEHICLE_CLASSES, type CarWashPayAt, type VehicleClass } 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; visionClasses?: VehicleClass[] }; 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, visionMap, }: { title: string; items: Item[]; onChange: (items: Item[]) => void; addLabel: string; /** Categories only: offer the vision vocabulary as chips under each row — the site's * own "car, sedan → Vetura" mapping (venue-modules.md §Vehicle category). */ visionMap?: boolean; }) { const { t } = useTranslation(); const toggleClass = (i: number, cls: VehicleClass) => onChange( items.map((x, j) => { if (j !== i) return x; const cur = new Set(x.visionClasses ?? []); cur.has(cls) ? cur.delete(cls) : cur.add(cls); return { ...x, visionClasses: VEHICLE_CLASSES.filter((c) => cur.has(c)) }; }), ); return (
{title}
{items.map((it, i) => (
onChange(items.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))} />
{visionMap && (
{t("wash.visionClasses")} {VEHICLE_CLASSES.map((cls) => { const on = (it.visionClasses ?? []).includes(cls); return ( ); })}
)}
))}
); } export function CarWashSetup({ canEdit }: { canEdit: boolean }) { const { t } = useTranslation(); const [settings, setSettings] = useState(null); const [categories, setCategories] = useState([]); const [services, setServices] = useState([]); /** 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>({}); /** Where the money is taken — a SITE policy (user, 2026-09-05), not a per-order radio. */ const [payAt, setPayAt] = useState("booth"); /** Confidence floor for a vision read to flag a downgrade (percent, as typed). */ const [threshold, setThreshold] = useState("80"); const [msg, setMsg] = useState(null); const [program, setProgram] = useState(null); function load() { fetchCarwashSettings() .then((s) => { setSettings(s); setCategories(s.categories.map((c) => ({ id: c.id, name: c.name, active: c.active, visionClasses: c.visionClasses }))); setServices(s.services.map((x) => ({ id: x.id, name: x.name, active: x.active }))); const p: Record = {}; for (const r of s.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor); setPrices(p); setPayAt(s.payAt); setThreshold(String(Math.round(s.visionThreshold * 100))); }) .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, visionClasses: c.visionClasses ?? [] })), 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 thr = Number(threshold); const saved = await saveCarwashSettings({ categories: cats.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active, visionClasses: c.visionClasses ?? [] })), services: svcs.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })), prices: priceRows, payAt, ...(Number.isFinite(thr) && thr >= 0 && thr <= 100 ? { visionThreshold: thr / 100 } : {}), }); setSettings(saved); setPayAt(saved.payAt); setThreshold(String(Math.round(saved.visionThreshold * 100))); setCategories(saved.categories.map((c) => ({ id: c.id, name: c.name, active: c.active, visionClasses: c.visionClasses }))); setServices(saved.services.map((x) => ({ id: x.id, name: x.name, active: x.active }))); const p: Record = {}; 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 (
{t("wash.prices")} {currency && ({currency})}
{t("wash.pricesHint")} {categories.length > 0 && services.length > 0 && (
{services.map((s, si) => ( ))} {categories.map((c, ci) => ( {services.map((s, si) => { const k = keyOf(c, ci, s, si); return ( ); })} ))}
{s.name || "…"}
{c.name || "…"} setPrices((p) => ({ ...p, [k]: e.target.value }))} placeholder="—" />
)}
{t("wash.payAt")}
{CARWASH_PAY_AT.map((v) => ( ))}
{t(payAt === "booth" ? "wash.payAtBoothHint" : "wash.payAtBayHint")}
{t("wash.visionThreshold")}
setThreshold(e.target.value)} /> %
{t("wash.visionThresholdHint")}
{canEdit && (
{msg && {msg}}
)}
{canEdit && program && (
{t("wash.sponsorship")}
{t("wash.sponsorshipHint")}
)}
); }