6505a4a73b
The entry button (physical press AND the operator-issued mint) requires
radar/loop presence + camera detection to confirm a real vehicle. When one
of those devices is faulty, the gate blocks legitimate transient entry. Let
the ADMIN drop a specific signal as a requirement until support fixes the
hardware — the admin is not the adversary, but weakening an anti-fraud gate
stays attributed and auditable:
- Granular: bypass radar and camera independently (Setup → controller
section). A dead camera drops only the camera check; a dead radar only
radar. Both off = normal gate; both on = press-to-print.
- Signed: a DEDICATED endpoint (PUT /api/site-config/presence-bypass,
site:update) appends a signed config_change {setting, value, prev,
operator} per actually-changed signal — new ledger type. No-op toggles
sign nothing; disabling signs too. Kept out of the generic site PUT.
- Flagged: every vehicle_entry issued (and every refusal anomaly) while
bypassed carries presenceBypassed:[...] in its signed payload.
- Persists until turned off; amber warning in Setup while active. The
booth entry light treats a bypassed signal as satisfied (server
re-checks authoritatively). Physical-button path falls through to the
cooldown backstop when radar is bypassed.
- Migration 0020: two boolean site_config columns (default off).
Fixes a latent bug surfaced by the tests: firstRelayByDirection returned no
presenceInput, so issueForOperator's radar gate always read "presence loop
unavailable" — operator-issue never actually gated on radar. The resolver
now attaches the presence input serving the relay (mirrors relayForButton).
10 new tests: 5 gate combinations (each bypass drops only its signal +
records it), 5 route tests (RBAC, signed transitions, no-op, validation).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
391 lines
16 KiB
TypeScript
391 lines
16 KiB
TypeScript
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 (
|
|
<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-[0.6875rem] 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-[0.6875rem] 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-[0.6875rem] 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.openTicket")}
|
|
</button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<div
|
|
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${blinking ? "lane-blink" : solid} ${
|
|
clickable ? "cursor-pointer hover:brightness-125" : ""
|
|
}`}
|
|
title={clickable ? t("booth.issueEntryTitle") : label}
|
|
onClick={canIssue ? onIssue : undefined}
|
|
role={clickable ? "button" : undefined}
|
|
>
|
|
{/* Barrier glyph: a post + an arm. `currentColor` follows the (possibly blinking) state. */}
|
|
<svg viewBox="0 0 24 24" className="h-5 w-5" 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-[0.625rem] uppercase tracking-wider text-term-muted">{label}</div>
|
|
<div className="text-xs font-bold">
|
|
{issuing ? "…" : clickable ? t("booth.issueEntry") : busy ? "●" : blinking ? "◐" : "○"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<div className="flex items-center gap-2">
|
|
<BarrierLight
|
|
label={t("booth.laneEntry")}
|
|
busy={lanes?.entry ?? false}
|
|
radar={radar?.entry ?? false}
|
|
onIssue={canIssue ? onIssue : undefined}
|
|
issuing={issue.isPending}
|
|
bypassRadar={site?.bypassPresenceRadar ?? false}
|
|
bypassCamera={site?.bypassPresenceCamera ?? false}
|
|
/>
|
|
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} radar={radar?.exit ?? false} />
|
|
{msg && (
|
|
<span className={`text-[0.6875rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>{msg.text}</span>
|
|
)}
|
|
</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 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<FeedCat | "">("");
|
|
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<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 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-[0.625rem] 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={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>
|
|
);
|
|
}
|