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
+63
View File
@@ -105,6 +105,66 @@ export const sq = {
evtShiftZ: "TURN Z",
evtCashMovement: "ARKË",
evtAnomaly: "ANOMALI",
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
evtNoReason: "pa arsye të regjistruar",
badgeEntryRefused: "hyrje e refuzuar",
badgeExitRefused: "dalje e refuzuar",
badgeLotFull: "parkimi plot",
badgeBarrierFailed: "barriera nuk u hap",
badgeManualOpen: "hapje manuale",
badgeSubRefused: "abonimi u refuzua",
badgeNoTicket: "bileta nuk u printua",
feedSourceBooth: "kabinë",
feedSourceReader: "lexues",
// dritarja e detajeve të eventit
eventDetail: "Detajet e eventit",
edType: "Lloji",
edTime: "Ora",
edIndex: "Indeksi në regjistër",
edDirection: "Drejtimi",
edSource: "Burimi",
edIdentity: "Identiteti",
edReason: "Arsyeja",
edSnapshots: "Fotot",
edPayload: "Të dhënat e nënshkruara",
edChain: "Zinxhiri",
edSignature: "Nënshkrimi",
edKeyId: "Çelësi",
edPrevHash: "Hash-i i mëparshëm",
edNoPayload: "Ky event nuk ka të dhëna shtesë.",
edCopy: "Kopjo",
edCopied: "U kopjua",
edDetails: "Detajet",
edAuditData: "Të dhënat e auditimit (nënshkrimi & zinxhiri)",
edAmount: "Shuma",
edTender: "Mënyra e pagesës",
edSession: "Sesioni",
edPlate: "Targa",
edCategory: "Kategoria",
edOperator: "Operatori",
edTariffVersion: "Versioni i tarifës",
edRawPayload: "Të dhënat e papërpunuara të nënshkruara",
edOccurrence: "ID e hyrjes",
subscriber: "Abonent",
},
// Mesazhet e përkthyera për REASON_CODES e nënshkruara (shih @parking/shared).
// Çelësat përputhen 1:1 me kodet; {{param}} mbushet nga reasonParams i eventit.
reason: {
"entry.refused.full": "Hyrja u refuzua — parkimi plot ({{count}}/{{capacity}})",
"entry.held.noTicket": "Hyrja u mbajt — bileta nuk u printua: {{detail}}",
"exit.refused.closed": "Dalja u refuzua — sesioni është mbyllur tashmë",
"exit.refused.noSession": "Dalja u refuzua — biletë e panjohur",
"exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)",
"exit.refused.graceExpired": "Dalja u refuzua — afati i daljes skadoi (kërkohet pagesë shtesë)",
"exit.open.noBarrier": "Dalja u regjistrua, por nuk ka barrierë daljeje të konfiguruar — hape me dorë",
"exit.open.unavailable": "Dalja u regjistrua, por barriera është e padisponueshme — hape me dorë",
"exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape me dorë",
"exit.freeGrace": "Periudhë pa pagesë në hyrje (pa tarifë)",
"exit.manualOpen": "Hapje manuale e barrierës (ndërhyrje njerëzore)",
"sub.refused.notFound": "Abonimi u refuzua — nuk u gjet",
"sub.refused.outOfWindow": "Abonimi u refuzua — {{status}}/jashtë afatit",
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
"sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)",
},
tariff: {
title: "Tarifa",
@@ -459,6 +519,9 @@ export const sq = {
// snapshots
noSnapshots: "asnjë foto",
loadingSnapshots: "duke ngarkuar fotot…",
snapEntry: "hyrje",
snapExit: "dalje",
snapFailed: "kamera e paarritshme",
},
};