Files
parking_solution/apps/web/src/modules/carwash/CarWashSetup.tsx
T
julian 5e1395db18 feat(carwash): advisory vehicle category from the entry camera — mapping, pre-select, downgrade flag
The app plumbing for venue-modules.md §"Vehicle category from vision"; the model is the
open half (no bundled recognizer emits body_type yet, so the desk shows nothing until
phase A lands in the vision service).

- Shared: VEHICLE_CLASSES vocabulary, VehicleRead, CARWASH_VISION_THRESHOLD_DEFAULT,
  reason code carwash.categoryDowngrade; settings/order/lookup views carry the read.
- Vision contract: /analyze vehicle.body_type + confidence (service schema); the Node
  client normalises to the vocabulary and drops the rest.
- Record: snapshot.ts stores the read in the plate's device_events row (or its own when
  the plate was unreadable); vehicleForIdentity() resolves it like the plate.
- Car wash: carwash_categories.vision_classes (site mapping "car, sedan → Vetura"),
  carwash_config.vision_threshold (signed config_change when it moves), four vision
  columns on orders — migration 0030. Lookup returns vision + suggestedCategoryId.
- Desk pre-selects the mapped category and shows the read + snapshot thumbnail; Setup
  offers class chips per category and the threshold. Operator decides.
- Flag: a read at/above the threshold whose mapped category prices HIGHER than the chosen
  one signs one `anomaly` (both categories/prices, operator, snapshot) and stores its id on
  the order. Equal/upgrade/unsure/unmapped → nothing. Recorded only, never blocks, no
  reason prompt (user, 2026-09-06).

Tests in carwash.test.ts; wiki venue-modules (As built), opencv-anpr-service, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 13:37:34 +02:00

282 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<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="grid gap-1">
<div 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>
{visionMap && (
<div className="flex flex-wrap items-center gap-1 pl-1">
<span className="mr-1 text-[0.625rem] uppercase tracking-wider text-term-muted">{t("wash.visionClasses")}</span>
{VEHICLE_CLASSES.map((cls) => {
const on = (it.visionClasses ?? []).includes(cls);
return (
<button key={cls} type="button" className={`btn btn-sm ${on ? "btn-primary" : "btn-ghost"}`} onClick={() => toggleClass(i, cls)}>
{t(`vehicleClass.${cls}`)}
</button>
);
})}
</div>
)}
</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");
/** Confidence floor for a vision read to flag a downgrade (percent, as typed). */
const [threshold, setThreshold] = useState("80");
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, visionClasses: c.visionClasses })));
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);
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<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")} visionMap />
<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>
<div className="field">
<span className="label">{t("wash.visionThreshold")}</span>
<div className="flex items-center gap-2">
<input className="input w-20 text-right tabular-nums" inputMode="numeric" value={threshold} disabled={!canEdit} onChange={(e) => setThreshold(e.target.value)} />
<span className="text-[0.75rem] text-term-muted">%</span>
</div>
<span className="hint">{t("wash.visionThresholdHint")}</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>
);
}