Files
parking_solution/apps/web/src/BoothScreen.tsx
T
julian e0b9442acc feat(booth): live lane busy/free barrier lights from camera vehicle detection
A Hikvision vehicle detection (eventType=VMD, targetType=vehicle) on a
camera bound to entry/exit now marks that lane "busy" and shows it as a
barrier light beside the scan input on the booth (green=free, red=busy).
Advisory only — it gates nothing (never blocks a ticket or opens a barrier).

- Parse eventState (active/inactive) from the Hik payload.
- LaneStatus tracker: a vehicle `active` event marks the camera's bound lane
  busy + arms an auto-clear timer. This camera class sends no leave/`inactive`
  signal, so "free" is timeout-driven (LANE_BUSY_TTL_MS, default 90s; the
  camera re-fires `active` while a car sits there, refreshing the timer). A
  "both"-direction camera marks both lanes.
- Push lane-status over the existing booth WS (+ in the hello snapshot);
  live-store holds { entry, exit }; two BarrierLight icons render it.
- i18n booth.laneEntry/laneExit (sq + en).

Tests: lane-status.test.ts (7 — busy/free, TTL auto-clear, timer re-arm,
no re-emit while busy, both/exit direction, unknown device). server 120/120;
web + server build/lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 17:42:54 +02:00

312 lines
13 KiB
TypeScript

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 { 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 (
<div className="flex flex-col gap-3">
<div className="flex items-end gap-4">
<div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
<div className="pb-1 text-term-muted">
<div className="text-[11px] uppercase tracking-wider">{t("booth.inside")}</div>
<div className="text-sm tabular-nums">
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
</div>
</div>
<div className="ml-auto text-right">
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("booth.free")}</div>
<div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
{occ.free == null ? "∞" : occ.free}
</div>
</div>
</div>
{pct != null && (
<div className="h-2 w-full overflow-hidden rounded-term bg-term-panel-2">
<div className={`h-full ${barColor} transition-[width] duration-300`} style={{ width: `${pct}%` }} />
</div>
)}
{occ.full && (
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[11px] font-bold uppercase tracking-widest text-term-red">
{t("booth.lotFull")}
</div>
)}
</div>
);
}
/** 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<HTMLInputElement>(null);
return (
<form
className="flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
const id = value.trim();
if (id) {
onSubmit(id);
setValue("");
ref.current?.focus();
}
}}
>
<input
ref={ref}
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={t("booth.scanPlaceholder")}
inputMode="numeric"
className="input h-11 flex-1 px-3 text-lg tabular-nums"
/>
<button type="submit" className="btn btn-primary btn-lg">
{t("booth.open")}
</button>
</form>
);
}
/** One barrier light — green = free, red = busy (a vehicle is at the lane vicinity,
* from camera detection). Advisory only; it gates nothing. */
function BarrierLight({ label, busy }: { label: string; busy: boolean }) {
return (
<div
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${
busy ? "border-term-red bg-term-red/10" : "border-term-green bg-term-green/10"
}`}
title={label}
>
{/* Barrier glyph: a post + an arm. Colour carries the state. */}
<svg viewBox="0 0 24 24" className={`h-5 w-5 ${busy ? "text-term-red" : "text-term-green"}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="5" y1="21" x2="5" y2="9" />
<line x1="5" y1="10" x2="21" y2="6" />
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
</svg>
<div className="leading-tight">
<div className="text-[10px] uppercase tracking-wider text-term-muted">{label}</div>
<div className={`text-xs font-bold ${busy ? "text-term-red" : "text-term-green"}`}>
{busy ? "●" : "○"}
</div>
</div>
</div>
);
}
/** The two lane barrier lights (entry / exit) fed by the live lane-status. */
function LaneIndicators() {
const { t } = useTranslation();
const lanes = useLiveStore((s) => s.lanes);
return (
<div className="flex items-center gap-2">
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} />
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} />
</div>
);
}
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<string | null>(null);
// The ledger event open in the read-only detail modal (null = closed).
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(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 category, and direction/source.
const [feedSearch, setFeedSearch] = useState("");
const [feedType, setFeedType] = useState<FeedCat | "">("");
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<FeedCat>[] = [
{ 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 (
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
{/* 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). */}
<div className="lg:col-span-2">
<Panel title={t("booth.processTicket")}>
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-[260px] flex-1">
<TicketInput onSubmit={setActiveTicket} />
</div>
<LaneIndicators />
</div>
</Panel>
</div>
{/* Left column: occupancy gauge above the active-sessions list. */}
<div className="flex min-h-0 flex-col gap-3">
<Panel title={t("booth.occupancy")} right={<StatusDot />}>
{occ ? (
<OccupancyGauge occ={occ} />
) : (
<div className="text-term-muted">{occQuery.isError ? t("booth.occUnavailable") : t("common.loading")}</div>
)}
</Panel>
<div className="flex min-h-0 flex-1 flex-col">
<ActiveSessions onPick={setActiveTicket} />
</div>
</div>
<Panel
title={t("booth.liveFeed")}
right={
<span className="text-[10px] uppercase tracking-wider text-term-muted">
{events.length}
{events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")}
</span>
}
className="min-h-0"
>
<div className="flex h-full flex-col">
{shiftOpen && (
<FilterBar search={feedSearch} onSearch={setFeedSearch} searchPlaceholder={t("booth.filterSearchFeed")}>
<SegGroup
value={feedType}
options={feedTypeOpts}
onChange={setFeedType}
allLabel={t("booth.filterAll")}
/>
<SegGroup value={feedDir} options={feedDirOpts} onChange={setFeedDir} allLabel={t("booth.filterAll")} />
<SegGroup value={feedSrc} options={feedSrcOpts} onChange={setFeedSrc} allLabel={t("booth.filterAll")} />
</FilterBar>
)}
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{!shiftOpen ? (
<div className="text-term-amber">{t("shift.gateTitle")}</div>
) : events.length === 0 ? (
<div className="text-term-muted">
{eventsQuery.isLoading
? t("common.loading")
: scoped.length === 0
? t("booth.noEventsYet")
: t("booth.noMatch")}
</div>
) : (
events.map((e) => <EventRow key={e.id} e={e} onOpen={setDetailEvent} />)
)}
</div>
</div>
</Panel>
{activeTicket && <BoothPayModal identity={activeTicket} onClose={() => setActiveTicket(null)} />}
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
</div>
);
}