import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.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"; // 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" }, anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" }, }; 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")}
)}
); } function EventRow({ e }: { e: LedgerEvent }) { const { t } = useTranslation(); const style = EVENT_STYLE[e.type]; const label = style ? t(style.labelKey) : e.type.toUpperCase(); return (
{hhmmss(e.occurredAt)} {label} {e.identity ?? "—"} #{e.index}
); } /** 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="flex-1 rounded-term border border-term-border bg-term-bg px-3 py-2 text-lg tabular-nums text-term-text placeholder:text-term-muted focus:border-term-amber" />
); } 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); // 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 events = shiftOpen && shiftStart ? merged.filter((e) => e.occurredAt >= shiftStart) : []; 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} {t("booth.events")} } className="min-h-0" >
{!shiftOpen ? (
{t("shift.gateTitle")}
) : events.length === 0 ? (
{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}
) : ( events.map((e) => ) )}
{activeTicket && setActiveTicket(null)} />}
); }