import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { can, fetchEvents, fetchOccupancy, fetchSiteConfig, issueEntryTicket, type LedgerEvent, type Occupancy } from "./api.js"; import { rootRoute } from "./router.js"; import { qk } from "./lib/query.js"; import { useLiveStore } from "./lib/live-store.js"; import { useShift } from "./lib/use-shift.js"; import { useScanner } from "./lib/use-scanner.js"; import { Panel } from "./ui/Panel.js"; import { StatusDot } from "./ui/StatusDot.js"; import { BoothPayModal } from "./BoothPayModal.js"; import { ActiveSessions } from "./ActiveSessions.js"; import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js"; import { EventDetailModal, EventRow } from "./ui/event-detail.js"; import { tillOfEvent } from "@parking/shared"; // 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. */ // 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 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")}
)}
); } /** 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" />
); } /** One barrier light — a 3-state indicator mirroring the physical button lamp (relay 3): * - radar present + camera NOT busy → BLINK green↔red (~1 Hz): "detected, not yet confirmed" * - camera busy → SOLID red: a vehicle is confirmed at the lane vicinity * - otherwise → SOLID green: free * Advisory only; it gates nothing. On the ENTRY light, when the operator holds `session:create` * and BOTH presence conditions meet (radar present AND camera busy = a real car at the entry), * the light becomes a CLICKABLE issue-ticket control (broken physical button). Same presence * rule as the physical button; the server re-checks it. See operator-issued-entry.md. */ function BarrierLight({ label, busy, radar, onIssue, issuing, bypassRadar, bypassCamera, }: { label: string; busy: boolean; radar: boolean; /** When set (entry light + permission), clicking issues an entry ticket — enabled when * both presence conditions are satisfied, treating a BYPASSED signal as satisfied. */ onIssue?: () => void; issuing?: boolean; /** Admin bypass of a faulty device: a bypassed signal counts as present (server re-checks). */ bypassRadar?: boolean; bypassCamera?: boolean; }) { const { t } = useTranslation(); // Blink only when the radar sees something the camera hasn't confirmed. const blinking = radar && !busy; const solid = busy ? "border-term-red bg-term-red/10 text-term-red" : "border-term-green bg-term-green/10 text-term-green"; // A bypassed signal counts as satisfied (its device is faulty). The SERVER re-checks the // effective gate authoritatively; this only governs button affordance. const radarOk = radar || !!bypassRadar; const cameraOk = busy || !!bypassCamera; const canIssue = !!onIssue && radarOk && cameraOk && !issuing; const clickable = !!onIssue && radarOk && cameraOk; return (
{/* Barrier glyph: a post + an arm. `currentColor` follows the (possibly blinking) state. */}
{label}
{issuing ? "…" : clickable ? t("booth.issueEntry") : busy ? "●" : blinking ? "◐" : "○"}
); } /** The two lane barrier lights (entry / exit) fed by the live lane-status (camera busy/free) * and lane-presence (radar). The ENTRY light doubles as an operator issue-ticket control when * the physical button is broken (permission + presence gated). */ function LaneIndicators() { const { t } = useTranslation(); const lanes = useLiveStore((s) => s.lanes); const radar = useLiveStore((s) => s.radar); const { user } = rootRoute.useRouteContext(); const { isOpen: shiftOpen, isMine } = useShift(); const qc = useQueryClient(); const canIssue = can(user, "session:create") && shiftOpen && isMine; // Presence-gate bypass flags (admin, for faulty radar/camera). Refetched on interval so a // toggle reaches the booth without a reload; the server still re-checks authoritatively. const { data: site } = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig, staleTime: 30_000, refetchInterval: 60_000, }); const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null); const issue = useMutation({ mutationFn: issueEntryTicket, onSuccess: (r) => { setMsg({ ok: true, text: t("booth.issueEntryOk", { ticket: r.ticketId }) }); void qc.invalidateQueries({ queryKey: qk.events }); void qc.invalidateQueries({ queryKey: qk.occupancy }); setTimeout(() => setMsg(null), 4000); }, onError: (e) => { setMsg({ ok: false, text: (e as Error).message }); setTimeout(() => setMsg(null), 4000); }, }); function onIssue() { if (window.confirm(t("booth.issueEntryConfirm"))) issue.mutate(); } return (
{msg && ( {msg.text} )}
); } 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, undefined, "booth"), 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); // A hardware scan opens the pay/exit modal regardless of focus (the operator needn't // click the ticket field first). Paused while a modal is already up — a scan must not // abandon an in-progress payment (the operator finishes/closes, then scans the next). useScanner({ onScan: setActiveTicket, paused: activeTicket != null || detailEvent != null }); // Live-feed filters: free-text search, event type, and source. (No direction filter — // HYRJE/DALJE there just duplicated the entry/exit options already in the Type filter.) const [feedSearch, setFeedSearch] = useState(""); const [feedType, setFeedType] = useState(""); 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) and to the BOOTH till (the // socket also pushes wash-desk events to anyone with carwash:read; they are the wash // shift's activity, not this one's — tillOfEvent). 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 && tillOfEvent(e.type, e.payload) === "booth") : []; // 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 the enriched advisory plate (`e.plate` — the displayed field; // the plate is NOT in the signed payload, so `payload.plate` would never match). const fq = feedSearch.trim().toLowerCase(); const events = scoped.filter((e) => { if (feedType && feedCat(e.type) !== feedType) 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.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 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. The lane barrier lights sit beside it (live vehicle-detection busy/free). */}
{/* 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)} />}
); }