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";
// 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 (
);
}
/** 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 (
);
}
/** 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. */}
);
}
/** 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),
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). 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 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. */}