feat: explainable activity log — reasons, subscriber names, snapshot gaps

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
This commit is contained in:
2026-06-19 10:57:17 +02:00
parent 040c0ff4ca
commit f31e57b4ae
15 changed files with 686 additions and 65 deletions
+64
View File
@@ -103,6 +103,67 @@ export const en: Catalog = {
evtShiftZ: "SHIFT Z",
evtCashMovement: "CASH",
evtAnomaly: "ANOMALY",
// live-feed event detail line + classification badges (computed from payload)
evtNoReason: "no reason recorded",
badgeEntryRefused: "entry refused",
badgeExitRefused: "exit refused",
badgeLotFull: "lot full",
badgeBarrierFailed: "barrier did not open",
badgeManualOpen: "manual open",
badgeSubRefused: "subscription refused",
badgeNoTicket: "ticket not printed",
feedSourceBooth: "booth",
feedSourceReader: "reader",
// event-detail modal
eventDetail: "Event detail",
edType: "Type",
edTime: "Time",
edIndex: "Ledger index",
edDirection: "Direction",
edSource: "Source",
edIdentity: "Identity",
edReason: "Reason",
edSnapshots: "Snapshots",
edPayload: "Signed payload",
edChain: "Chain",
edSignature: "Signature",
edKeyId: "Key",
edPrevHash: "Prev hash",
edNoPayload: "No payload on this event.",
edCopy: "Copy",
edCopied: "Copied",
edDetails: "Details",
edAuditData: "Audit data (signature & chain)",
edAmount: "Amount",
edTender: "Tender",
edSession: "Session",
edPlate: "Plate",
edCategory: "Category",
edOperator: "Operator",
edTariffVersion: "Tariff version",
edRawPayload: "Raw signed payload",
edOccurrence: "Occurrence id",
subscriber: "Subscriber",
},
// Localized messages for the signed REASON_CODES (see @parking/shared). Keys MUST
// match the codes 1:1; {{param}} placeholders are filled from the event's
// reasonParams. Legacy events with no code fall back to the signed English `reason`.
reason: {
"entry.refused.full": "Entry refused — lot full ({{count}}/{{capacity}})",
"entry.held.noTicket": "Entry held — ticket not printed: {{detail}}",
"exit.refused.closed": "Exit refused — session already closed",
"exit.refused.noSession": "Exit refused — no open session for ticket",
"exit.refused.unpaid": "Exit refused — not paid (take payment first)",
"exit.refused.graceExpired": "Exit refused — walk-back grace expired (top-up required)",
"exit.open.noBarrier": "Exit recorded, but no exit barrier is configured — open manually",
"exit.open.unavailable": "Exit recorded, but the barrier is unavailable — open manually",
"exit.open.failed": "Exit recorded, but the barrier did not open — open manually",
"exit.freeGrace": "Free entry-grace (no charge)",
"exit.manualOpen": "Manual barrier open (human intervention)",
"sub.refused.notFound": "Subscription refused — not found",
"sub.refused.outOfWindow": "Subscription refused — {{status}}/out-of-window",
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
},
tariff: {
title: "Tariff",
@@ -446,5 +507,8 @@ export const en: Catalog = {
reprinting: "printing…",
noSnapshots: "no snapshots",
loadingSnapshots: "loading snapshots…",
snapEntry: "entry",
snapExit: "exit",
snapFailed: "camera unreachable",
},
};