import { useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js"; import { formatMoney } from "./lib/format.js"; import { qk } from "./lib/query.js"; import { useLiveStore } from "./lib/live-store.js"; import { useShift } from "./lib/use-shift.js"; import { Panel } from "./ui/Panel.js"; import { StatusDot } from "./ui/StatusDot.js"; import { BoothPayModal } from "./BoothPayModal.js"; import { ActiveSessions } from "./ActiveSessions.js"; import { Modal } from "./ui/Modal.js"; import { SnapshotStrip } from "./ui/SnapshotStrip.js"; import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js"; import { renderReason } from "./lib/reason.js"; // The live operator booth view — the real-time heart of the console. Occupancy // gauge + a streaming entry/exit/payment ticker. Query owns the initial load and // the authoritative numbers; the WS-fed live store overlays real-time updates so // the screen reacts the instant a car enters or exits. Dense, dark, glanceable. /** Per-event-type display: i18n label key + accent colour for the ticker. */ 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" }, anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" }, }; // Live-feed filter category for an event type. Several ledger types collapse into a // few operator-meaningful buckets; the rest (barrier/shift/cash) fall outside the // filter and only show under "all". type FeedCat = "entry" | "exit" | "pay" | "void" | "anomaly"; function feedCat(type: string): FeedCat | null { switch (type) { case "vehicle_entry": return "entry"; case "vehicle_exit": return "exit"; case "payment": return "pay"; case "void": return "void"; case "anomaly": return "anomaly"; default: return null; } } function hhmmss(iso: string): string { // Local time-of-day, terminal style. Defensive against a bad timestamp. const d = new Date(iso); return Number.isNaN(d.getTime()) ? "--:--:--" : d.toTimeString().slice(0, 8); } function OccupancyGauge({ occ }: { occ: Occupancy }) { const { t } = useTranslation(); const pct = occ.capacity ? Math.min(100, Math.round((occ.count / occ.capacity) * 100)) : null; const barColor = occ.full ? "bg-term-red" : pct != null && pct >= 85 ? "bg-term-amber" : "bg-term-green"; return (
{occ.count}
{t("booth.inside")}
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
{t("booth.free")}
{occ.free == null ? "∞" : occ.free}
{pct != null && (
)} {occ.full && (
{t("booth.lotFull")}
)}
); } /** Translated classification badges derived from a payload's boolean flags. Unlike * `reason` (an immutable English sentence baked into the signed ledger, shown * verbatim), these are computed client-side so they CAN be localized. They give a * glanceable "what kind of anomaly" tag without parsing the free-text reason. */ 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/exited outside their plan's allowed window → owes a deferred // transient charge, collected (gated) at exit. Flag it so the operator KNOWS now. if (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. Lets the activity * log show HOW a subscriber entered/left — QR code, RFID card/chip, or plate. */ 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"). */ 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. */ function displayIdentity(e: LedgerEvent): string { return e.subscriberLabel ?? e.identity ?? "—"; } function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) { const { t } = useTranslation(); const style = EVENT_STYLE[e.type]; const label = style ? t(style.labelKey) : e.type.toUpperCase(); const isAnomaly = e.type === "anomaly"; const p = e.payload; // Localize the reason from the signed reasonCode (falls back to the English text on // legacy events). Anomalies ALWAYS get a detail line so a red flag is never silent. const reason = renderReason(p, t); const amount = paymentSummary(p); const badges = eventBadges(p); const via = viaKey(p); const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null); const showDetail = detail != null || badges.length > 0 || via != null; // The whole row is a button → opens the event-detail modal (full payload + the // session's entry/exit snapshots). A grid keeps the time/label/identity/index // columns aligned across rows; the detail line lives in its own row, indented to // start under the identity column so it never collides with the ticket code. 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. */ function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) { const { t } = useTranslation(); const style = EVENT_STYLE[e.type]; const label = style ? t(style.labelKey) : e.type.toUpperCase(); const p = e.payload; const reason = renderReason(p, t); const amount = paymentSummary(p); const badges = eventBadges(p); const isAnomaly = e.type === "anomaly"; // 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; 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}} {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")}
)}
); } /** Ticket entry: an HID barcode scanner types the id and presses Enter; a manual * operator types it. Either way, submit opens the pay/exit modal for that id. The * input auto-focuses and re-focuses after a scan so the scanner always lands here. */ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) { const { t } = useTranslation(); const [value, setValue] = useState(""); const ref = useRef(null); return (
{ e.preventDefault(); const id = value.trim(); if (id) { onSubmit(id); setValue(""); ref.current?.focus(); } }} > setValue(e.target.value)} placeholder={t("booth.scanPlaceholder")} inputMode="numeric" className="input h-11 flex-1 px-3 text-lg tabular-nums" />
); } export function BoothScreen() { const { t } = useTranslation(); // The site-wide shift drives the log scope: the feed shows ONLY the open shift's // window (per-shift logs, not all history). When no shift is open, the feed is // empty and the operator is prompted to open one. const { isOpen: shiftOpen, startedAt: shiftStart } = useShift(); // Initial load via Query (also the fallback if the WS is briefly down). The events // query is scoped to the current shift's start so it never shows prior shifts. const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy }); const eventsQuery = useQuery({ queryKey: [...qk.events, shiftStart ?? "none"], queryFn: () => fetchEvents(100, shiftStart ?? undefined), enabled: shiftOpen, }); // The ticket currently open in the pay/exit modal (null = no modal). const [activeTicket, setActiveTicket] = useState(null); // The ledger event open in the read-only detail modal (null = closed). const [detailEvent, setDetailEvent] = useState(null); // Live-feed filters: free-text search, event category, and direction/source. const [feedSearch, setFeedSearch] = useState(""); const [feedType, setFeedType] = useState(""); const [feedDir, setFeedDir] = useState<"entry" | "exit" | "">(""); const [feedSrc, setFeedSrc] = useState<"booth" | "reader" | "">(""); // Live overlays from the WS store. const liveOcc = useLiveStore((s) => s.occupancy); const liveFeed = useLiveStore((s) => s.feed); // Prefer the live-pushed occupancy; fall back to the query. const occ = liveOcc ?? occQuery.data ?? null; // Merge: live events first (newest), then the queried history, de-duped by id — // then clip to the current shift window (the live store spans shifts; the feed // must not show events from before this shift's start). No shift → no feed. const seen = new Set(liveFeed.map((e) => e.id)); const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id)); const merged = [...liveFeed, ...history].slice(0, 200); const scoped = shiftOpen && shiftStart ? merged.filter((e) => e.occurredAt >= shiftStart) : []; // Apply the live-feed filters. Source maps to booth (operator-initiated `manual`) // vs reader (device-initiated: wiegand/lpr/qr/ticket). Search spans identity, // subscriber label, and any advisory plate on the payload. const fq = feedSearch.trim().toLowerCase(); const events = scoped.filter((e) => { if (feedType && feedCat(e.type) !== feedType) return false; if (feedDir && e.direction !== feedDir) return false; if (feedSrc) { const isBooth = e.source === "manual"; if (feedSrc === "booth" ? !isBooth : isBooth) return false; } if (fq) { const hay = `${e.identity ?? ""} ${e.subscriberLabel ?? ""} ${e.payload?.plate ?? ""}`.toLowerCase(); if (!hay.includes(fq)) return false; } return true; }); const feedTypeOpts: SegOption[] = [ { value: "entry", label: t("booth.fEvtEntry") }, { value: "exit", label: t("booth.fEvtExit") }, { value: "pay", label: t("booth.fEvtPay") }, { value: "void", label: t("booth.fEvtVoid") }, { value: "anomaly", label: t("booth.fEvtAnomaly") }, ]; const feedDirOpts: SegOption<"entry" | "exit">[] = [ { value: "entry", label: t("booth.fDirEntry") }, { value: "exit", label: t("booth.fDirExit") }, ]; const feedSrcOpts: SegOption<"booth" | "reader">[] = [ { value: "booth", label: t("booth.fSrcBooth") }, { value: "reader", label: t("booth.fSrcReader") }, ]; return (
{/* Ticket input spans both columns at the top — the operator's primary action. */}
{/* Left column: occupancy gauge above the active-sessions list. */}
}> {occ ? ( ) : (
{occQuery.isError ? t("booth.occUnavailable") : t("common.loading")}
)}
{events.length} {events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")} } className="min-h-0" >
{shiftOpen && ( )}
{!shiftOpen ? (
{t("shift.gateTitle")}
) : events.length === 0 ? (
{eventsQuery.isLoading ? t("common.loading") : scoped.length === 0 ? t("booth.noEventsYet") : t("booth.noMatch")}
) : ( events.map((e) => ) )}
{activeTicket && setActiveTicket(null)} />} {detailEvent && setDetailEvent(null)} />}
); }