feat(vision): surface the recognized plate in the booth UI

The ANPR plate was saved (device_events kind:"read") but had no UI. Extend
GET /api/snapshots/by-identity/:identity to also return plates[] (plate, confidence,
region, direction, snapshotId, at) for that session, and render each as a cyan
"Plate: AA558EE 100%" chip in the SnapshotStrip — so it shows in both the booth
event-detail modal and the pay modal, beside the evidence photo, no separate screen.
Deduped by plate+direction; session:read gated; i18n sq+en.

Verified: by-identity returns plates[] for a seeded read (200, AA558EE 0.999 Albania
entry). Build + lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-19 17:23:32 +02:00
parent ecaaefd899
commit 9ec644811a
7 changed files with 100 additions and 5 deletions
+13 -2
View File
@@ -789,11 +789,22 @@ export interface SnapshotFailure {
occurredAt: string;
}
/** A licence plate recognized for this session by the ANPR-on-snapshot path (advisory
* record — see opencv-anpr-service.md). `snapshotId` links to the image it was read from. */
export interface PlateRead {
plate: string;
confidence: number | null;
region: string | null;
direction: "entry" | "exit" | null;
snapshotId: string | null;
at: string;
}
/** Snapshot metadata for a session identity (newest first) PLUS failed capture
* attempts. Image bytes are at `/api/snapshots/:id` — use that as an <img src>. */
* attempts PLUS any recognized plates. Image bytes are at `/api/snapshots/:id`. */
export function fetchSnapshots(
identity: string,
): Promise<{ snapshots: SnapshotMeta[]; failures?: SnapshotFailure[] }> {
): Promise<{ snapshots: SnapshotMeta[]; failures?: SnapshotFailure[]; plates?: PlateRead[] }> {
return apiFetch(`/api/snapshots/by-identity/${encodeURIComponent(identity)}`);
}
+1
View File
@@ -558,5 +558,6 @@ export const en: Catalog = {
snapEntry: "entry",
snapExit: "exit",
snapFailed: "camera unreachable",
plate: "Plate",
},
};
+1
View File
@@ -573,6 +573,7 @@ export const sq = {
snapEntry: "hyrje",
snapExit: "dalje",
snapFailed: "kamera e paarritshme",
plate: "Targa",
},
};
+38 -2
View File
@@ -1,7 +1,20 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { fetchSnapshots, snapshotImageUrl } from "../api.js";
import { fetchSnapshots, snapshotImageUrl, type PlateRead } from "../api.js";
/** Keep one entry per (plate, direction) — newest wins (the list is newest-first). */
function dedupePlates(plates: PlateRead[]): PlateRead[] {
const seen = new Set<string>();
const out: PlateRead[] = [];
for (const p of plates) {
const key = `${p.plate}|${p.direction}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(p);
}
return out;
}
// Entry/exit evidence images for a session. Lets the operator verify the car at the
// booth against the ticket. Thumbnails load from /api/snapshots/:id (cookie-authed,
@@ -18,17 +31,40 @@ export function SnapshotStrip({ identity }: { identity: string }) {
const shots = data?.snapshots ?? [];
const failures = data?.failures ?? [];
const plates = data?.plates ?? [];
/** Localized direction label for a snapshot/failure tile. */
const dirLabel = (dir: "entry" | "exit" | null): string =>
dir === "entry" ? t("pay.snapEntry") : dir === "exit" ? t("pay.snapExit") : "—";
if (isLoading) return <div className="text-[11px] text-term-muted">{t("pay.loadingSnapshots")}</div>;
if (shots.length === 0 && failures.length === 0)
if (shots.length === 0 && failures.length === 0 && plates.length === 0)
return <div className="text-[11px] text-term-muted">{t("pay.noSnapshots")}</div>;
return (
<>
{/* Recognized plate(s) (ANPR) — advisory. Dedup by plate+direction so an
entry+exit read of the same plate shows once per direction. */}
{plates.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{dedupePlates(plates).map((p, i) => (
<span
key={`${p.plate}-${p.direction}-${i}`}
className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[11px]"
title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${
p.at ? ` · ${new Date(p.at).toLocaleString()}` : ""
}`}
>
<span className="text-[9px] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
<span className="font-mono font-semibold text-term-cyan">{p.plate}</span>
{typeof p.confidence === "number" && (
<span className="text-[10px] text-term-muted">{(p.confidence * 100).toFixed(0)}%</span>
)}
</span>
))}
</div>
)}
<div className="flex flex-wrap gap-2">
{shots.map((s) => (
<button