5e1395db18
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
282 lines
13 KiB
TypeScript
282 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|