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:
@@ -68,7 +68,44 @@ export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void
|
||||
});
|
||||
}
|
||||
|
||||
return { snapshots: rows, failures };
|
||||
// Recognized PLATES for this session: kind="read" telemetry from the ANPR-on-
|
||||
// snapshot path (snapshot.ts → recognizePlate). Advisory — a record of the plate
|
||||
// observed for the session, shown beside the image. Newest first.
|
||||
const plateRows = db
|
||||
.select({ detail: deviceEvents.detail, occurredAt: deviceEvents.occurredAt })
|
||||
.from(deviceEvents)
|
||||
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
|
||||
.orderBy(desc(deviceEvents.occurredAt))
|
||||
.all();
|
||||
const plates: {
|
||||
plate: string;
|
||||
confidence: number | null;
|
||||
region: string | null;
|
||||
direction: "entry" | "exit" | null;
|
||||
snapshotId: string | null;
|
||||
at: string;
|
||||
}[] = [];
|
||||
for (const row of plateRows) {
|
||||
const d = (row.detail ?? {}) as {
|
||||
identity?: string;
|
||||
plate?: string;
|
||||
confidence?: number;
|
||||
region?: string | null;
|
||||
direction?: string;
|
||||
snapshotId?: string;
|
||||
};
|
||||
if (d.identity !== identity || !d.plate) continue;
|
||||
plates.push({
|
||||
plate: d.plate,
|
||||
confidence: typeof d.confidence === "number" ? d.confidence : null,
|
||||
region: d.region ?? null,
|
||||
direction: d.direction === "entry" || d.direction === "exit" ? d.direction : null,
|
||||
snapshotId: d.snapshotId ?? null,
|
||||
at: row.occurredAt ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
return { snapshots: rows, failures, plates };
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+13
-2
@@ -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)}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -558,5 +558,6 @@ export const en: Catalog = {
|
||||
snapEntry: "entry",
|
||||
snapExit: "exit",
|
||||
snapFailed: "camera unreachable",
|
||||
plate: "Plate",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -573,6 +573,7 @@ export const sq = {
|
||||
snapEntry: "hyrje",
|
||||
snapExit: "dalje",
|
||||
snapFailed: "kamera e paarritshme",
|
||||
plate: "Targa",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user