import { useTranslation } from "react-i18next"; import { type ReactNode } from "react"; import { type LedgerEvent } from "../api.js"; import { formatMoney } from "../lib/format.js"; import { renderReason } from "../lib/reason.js"; import { Modal } from "./Modal.js"; import { SnapshotStrip } from "./SnapshotStrip.js"; // Shared ledger-event presentation: the colour/label map, the clickable feed ROW, and // the read-only DETAIL modal (full signed payload + snapshots + chain provenance). Used // by the booth live feed AND the shift activity log so both render — and open — events // identically. See wiki/concepts/append-only-event-chain.md. export const EVENT_STYLE: Record = { vehicle_entry: { labelKey: "booth.evtEntry", color: "text-term-green" }, vehicle_exit: { labelKey: "booth.evtExit", color: "text-term-red" }, payment: { labelKey: "booth.evtPay", color: "text-term-cyan" }, void: { labelKey: "booth.evtVoid", color: "text-term-amber" }, barrier_open_command: { labelKey: "booth.evtOpenCmd", color: "text-term-muted" }, barrier_open_observed: { labelKey: "booth.evtOpenObserved", color: "text-term-muted" }, shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" }, shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" }, cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" }, cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" }, cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" }, cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" }, anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" }, }; /** * A refused-ACTION event is a benign WARNING, not a red-flag anomaly. The ledger type is * `anomaly` for both (immutable history), but a refused exit / refused subscription / * refused entry (e.g. a double card-scan, an at-capacity subscriber, an already-closed * session) is an EXPECTED outcome — not fraud. We classify it from the payload flags the * flows already sign (`exitRefused` / `entryRefused` / `permitRefused`) and show it as an * amber "REFUZUAR / REFUSED" warning, reserving red "ANOMALI" for genuine anomalies * (barrier-open failure, opened-without-ticket, …). Display-only — no ledger change. */ export function isRefusedWarning(e: LedgerEvent): boolean { if (e.type !== "anomaly") return false; const p = e.payload; return !!(p && (p.exitRefused || p.entryRefused || p.permitRefused)); } /** The label key + colour to render for an event, applying the refused-warning split. */ export function eventStyleFor(e: LedgerEvent): { labelKey: string; color: string } { if (isRefusedWarning(e)) return { labelKey: "booth.evtRefused", color: "text-term-amber" }; return EVENT_STYLE[e.type] ?? { labelKey: "", color: "text-term-text" }; } /** Local time-of-day, terminal style. Defensive against a bad timestamp. */ function hhmmss(iso: string): string { const d = new Date(iso); return Number.isNaN(d.getTime()) ? "--:--:--" : d.toTimeString().slice(0, 8); } /** Red-flag classification badges computed from the signed payload. */ export function eventBadges(p: LedgerEvent["payload"]): string[] { if (!p) return []; const keys: string[] = []; if (p.entryRefused) keys.push("booth.badgeEntryRefused"); if (p.exitRefused) keys.push("booth.badgeExitRefused"); if (p.full) keys.push("booth.badgeLotFull"); if (p.exitOpenFailed) keys.push("booth.badgeBarrierFailed"); if (p.permitRefused) keys.push("booth.badgeSubRefused"); if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket"); if (p.subscriptionSale) keys.push("booth.badgeSubSale"); // Subscriber entered outside their plan's allowed window → will owe a transient charge // for the minutes actually parked out-of-window, priced + collected (gated) at exit. // (`windowOwedMinor` is the old fixed-amount stamp, kept so historic events still badge.) if (p.outOfWindow === true || (typeof p.windowOwedMinor === "number" && p.windowOwedMinor > 0)) keys.push("booth.badgeWindowCharge"); if (p.source === "manual" && !p.subscriptionSale) keys.push("booth.badgeManualOpen"); return keys; } /** The i18n key for a subscriber's access medium (`via`), or null. */ export function viaKey(p: LedgerEvent["payload"]): string | null { if (!p) return null; if (p.via === "qr") return "booth.viaQr"; if (p.via === "card") return "booth.viaCard"; if (p.via === "plate") return "booth.viaPlate"; return null; } /** A short money summary for payment events (e.g. "350.00 ALL"). */ export function paymentSummary(p: LedgerEvent["payload"]): string | null { if (!p || typeof p.amountMinor !== "number" || !p.currency) return null; return formatMoney(p.amountMinor, p.currency); } /** What to SHOW for an event's actor. A subscription occurrence has an opaque * `SUBSESS-…` identity; the server resolves the holder's name into `subscriberLabel`, * so we show that (e.g. "Aqif Kopertoni") instead. Otherwise the identity itself. */ export function displayIdentity(e: LedgerEvent): string { return e.subscriberLabel ?? e.identity ?? "—"; } /** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps the * time/label/#index columns aligned across rows; the identity, plate, badges and reason flow * inline in the middle column and wrap there only when they run out of width. */ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) { const { t } = useTranslation(); const style = eventStyleFor(e); const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase(); // A refused-action event is a benign WARNING (amber), distinct from a genuine red // anomaly. Only true anomalies get the red row tint + the "no reason" fallback. const refusedWarning = isRefusedWarning(e); const isAnomaly = e.type === "anomaly" && !refusedWarning; const p = e.payload; const reason = renderReason(p, t); const amount = paymentSummary(p); const badges = eventBadges(p); const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null); return ( ); } /** One label/value line in the event-detail modal. */ function DetailRow({ label, children }: { label: string; children: ReactNode }) { return (
{label} {children}
); } /** Full read-only detail for one ledger event: business fields + the human-readable * reason + the session's entry/exit snapshots, then the signed-chain provenance * (signature/prev-hash/key) for an audit trail. Read-only — the ledger is immutable; * this only DISPLAYS the signed record. */ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) { const { t } = useTranslation(); const style = eventStyleFor(e); const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase(); const p = e.payload; const reason = renderReason(p, t); const badges = eventBadges(p); const isAnomaly = e.type === "anomaly" && !isRefusedWarning(e); // Pretty money for any minor-unit amount in the payload. const money = p && typeof p.amountMinor === "number" && typeof p.currency === "string" ? formatMoney(p.amountMinor, p.currency) : null; // Pull out the business fields worth a labelled row. Everything else (and the raw // bytes) lives behind the audit disclosure — the operator sees a clean summary. const sessionRef = typeof p?.sessionRef === "string" ? p.sessionRef : null; const plate = typeof p?.plate === "string" ? p.plate : null; const category = typeof p?.category === "string" ? p.category : null; const operator = typeof p?.operator === "string" ? p.operator : null; const tariffVersionId = typeof p?.tariffVersionId === "string" ? p.tariffVersionId : null; // cash_review fields: the admin's decision on a drawer movement (+ who / note / the // reviewed movement id). A flag only — it never moves cash. See wiki/concepts/shift.md. const decision = p?.decision === "authorize" || p?.decision === "deny" ? p.decision : null; const reviewedBy = typeof p?.reviewedBy === "string" ? p.reviewedBy : null; const reviewNote = typeof p?.note === "string" ? p.note : null; const refId = typeof p?.refId === "string" ? p.refId : null; return (
{/* Headline: the type + localized reason, prominent for anomalies. */}
{label}
{(reason || money) && (
{reason ?? money}
)} {!reason && !money && isAnomaly && (
{t("booth.evtNoReason")}
)} {badges.length > 0 && (
{badges.map((k) => ( {t(k)} ))}
)}
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
{new Date(e.occurredAt).toLocaleString()} #{e.index} {e.direction && {e.direction}} {e.source && {e.source}} {displayIdentity(e)} {/* When we showed a subscriber NAME above, also expose the raw occurrence id (the SUBSESS-… session key) for traceability against the ledger. */} {e.subscriberLabel && e.identity && ( {e.identity} )} {money && ( {money} )} {typeof p?.tender === "string" && {p.tender}} {viaKey(p) && ( {t(viaKey(p)!)} )} {category && {category}} {plate && {plate}} {operator && {operator}} {/* cash_review: the admin's decision + who + why (for a denial). */} {decision && ( {t(`booth.decision.${decision}`)} )} {reviewedBy && {reviewedBy}} {reviewNote && {reviewNote}} {refId && ( {refId} )} {sessionRef && sessionRef !== e.identity && ( {sessionRef} )} {tariffVersionId && ( {tariffVersionId} )}
{/* The entry/exit evidence images for this session's identity. */} {e.identity && (
{t("booth.edSnapshots")}
)} {/* Audit data — collapsed by default. The signed-chain provenance (signature, key, prev-hash) and the raw payload are an auditor's concern, not the operator's; tucking them behind a disclosure keeps the common view clean while preserving the tamper-evidence trail on demand. */}
{t("booth.edAuditData")}
{e.signature} {e.keyId} {e.prevHash ?? "—"}
{t("booth.edRawPayload")}
{p && Object.keys(p).length > 0 ? (
                {JSON.stringify(p, null, 2)}
              
) : (
{t("booth.edNoPayload")}
)}
); }