f31e57b4ae
The live activity feed flagged anomalies with no explanation and showed opaque session keys. Make events self-describing and clickable. - Clickable feed rows → read-only event-detail modal: humanized fields, entry/exit snapshots, and signed-chain provenance collapsed behind an audit disclosure (operator sees the story, auditor expands for crypto). - Localized reason codes (backend i18n): the signed ledger now carries a stable REASON_CODE + params (+ English fallback) instead of free-text English. The UI translates via reason.<code> catalogs in sq/en, so an Albanian operator reads Albanian — from the same immutable event. Adding a language is a catalog change, no re-signing. (@parking/shared REASON_CODES, reasonPayload; entry/exit/subscription flows emit codes.) - Subscriber-name resolution: a SUBSESS-… occurrence now shows the subscription holder's name (fallback "Abonent"/"Subscriber"). Resolved read-time server-side (events API + WS push) as a non-signed subscriberLabel; cached with invalidation on subscription edit/delete. - Failed-snapshot visibility: a camera that was attempted but unreachable now shows a "⚠ camera unreachable" tile instead of a silent gap. The snapshots API returns failures[] from telemetry, filtered so a recovered capture shows no stale warning. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
84 lines
3.5 KiB
TypeScript
84 lines
3.5 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { fetchSnapshots, snapshotImageUrl } from "../api.js";
|
|
|
|
// 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,
|
|
// served with a long immutable cache); clicking one enlarges it. Read-only.
|
|
|
|
export function SnapshotStrip({ identity }: { identity: string }) {
|
|
const { t } = useTranslation();
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ["snapshots", identity],
|
|
queryFn: () => fetchSnapshots(identity),
|
|
enabled: !!identity,
|
|
});
|
|
const [zoom, setZoom] = useState<string | null>(null);
|
|
|
|
const shots = data?.snapshots ?? [];
|
|
const failures = data?.failures ?? [];
|
|
|
|
/** 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)
|
|
return <div className="text-[11px] text-term-muted">{t("pay.noSnapshots")}</div>;
|
|
|
|
return (
|
|
<>
|
|
<div className="flex flex-wrap gap-2">
|
|
{shots.map((s) => (
|
|
<button
|
|
key={s.id}
|
|
type="button"
|
|
onClick={() => setZoom(s.id)}
|
|
className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber"
|
|
title={`${dirLabel(s.direction)} · ${new Date(s.capturedAt).toLocaleString()}`}
|
|
>
|
|
<img
|
|
src={snapshotImageUrl(s.id)}
|
|
alt={s.direction ?? "snapshot"}
|
|
className="h-20 w-28 object-cover"
|
|
loading="lazy"
|
|
/>
|
|
<span
|
|
className={`text-[9px] uppercase tracking-wider ${
|
|
s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted"
|
|
}`}
|
|
>
|
|
{dirLabel(s.direction)}
|
|
</span>
|
|
</button>
|
|
))}
|
|
|
|
{/* Failed captures — a placeholder tile so an absent image is explained, not
|
|
silently missing. Shown only when no successful shot exists for the same
|
|
direction (the server already filters recovered captures out). */}
|
|
{failures.map((f, i) => (
|
|
<div
|
|
key={`fail-${f.direction ?? "both"}-${i}`}
|
|
className="flex h-[6.75rem] w-28 flex-col items-center justify-center gap-1 rounded-term border border-dashed border-term-amber/60 bg-term-amber/5 p-1 text-center"
|
|
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${new Date(f.occurredAt).toLocaleString()}` : ""}`}
|
|
>
|
|
<span className="text-lg leading-none text-term-amber">⚠</span>
|
|
<span className="text-[9px] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
|
|
<span className="px-1 text-[9px] leading-tight text-term-muted">{t("pay.snapFailed")}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{zoom && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6"
|
|
onClick={() => setZoom(null)}
|
|
>
|
|
<img src={snapshotImageUrl(zoom)} alt="snapshot" className="max-h-full max-w-full object-contain" />
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|