eafbc3ddbb
A subscriber entering outside their plan's allowed window gets a deferred transient charge (windowOwedMinor, collected/gated at exit) — but it was SILENT at the booth: the entry showed as a plain subscriber pass with no hint money is owed, so the operator only discovers it at exit. Surface it: add a "out-of-window — owes fee" badge on any entry/exit event carrying windowOwedMinor > 0, so the operator sees immediately that this subscriber owes a fee. Also type the window-charge fields on LedgerPayload (were riding the open-ended index signature). Behaviour is otherwise unchanged and correct — verified the live "Mon Kukaleshi" entry: entered 20:29 local (before the 21:00 Mon–Sat window, grace 5m), owes 100 ALL for 18:29–18:55Z, stamped on the signed entry, still owed, gated at exit. Subscribers get no ticket by design. Build+lint 12/12. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
540 lines
24 KiB
TypeScript
540 lines
24 KiB
TypeScript
import { useRef, useState, type ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
|
import { formatMoney } from "./lib/format.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";
|
|
import { Modal } from "./ui/Modal.js";
|
|
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
|
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
|
import { renderReason } from "./lib/reason.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<string, { labelKey: string; color: string }> = {
|
|
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" },
|
|
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
|
|
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
|
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
|
};
|
|
|
|
// 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 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 (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
/** Translated classification badges derived from a payload's boolean flags. Unlike
|
|
* `reason` (an immutable English sentence baked into the signed ledger, shown
|
|
* verbatim), these are computed client-side so they CAN be localized. They give a
|
|
* glanceable "what kind of anomaly" tag without parsing the free-text reason. */
|
|
function eventBadges(p: LedgerEvent["payload"]): string[] {
|
|
if (!p) return [];
|
|
const keys: string[] = [];
|
|
if (p.entryRefused) keys.push("booth.badgeEntryRefused");
|
|
if (p.exitRefused) keys.push("booth.badgeExitRefused");
|
|
if (p.full) keys.push("booth.badgeLotFull");
|
|
if (p.exitOpenFailed) keys.push("booth.badgeBarrierFailed");
|
|
if (p.permitRefused) keys.push("booth.badgeSubRefused");
|
|
if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket");
|
|
if (p.subscriptionSale) keys.push("booth.badgeSubSale");
|
|
// Subscriber entered/exited outside their plan's allowed window → owes a deferred
|
|
// transient charge, collected (gated) at exit. Flag it so the operator KNOWS now.
|
|
if (typeof p.windowOwedMinor === "number" && p.windowOwedMinor > 0) keys.push("booth.badgeWindowCharge");
|
|
if (p.source === "manual" && !p.subscriptionSale) keys.push("booth.badgeManualOpen");
|
|
return keys;
|
|
}
|
|
|
|
/** The i18n key for a subscriber's access medium (`via`), or null. Lets the activity
|
|
* log show HOW a subscriber entered/left — QR code, RFID card/chip, or plate. */
|
|
function viaKey(p: LedgerEvent["payload"]): string | null {
|
|
if (!p) return null;
|
|
if (p.via === "qr") return "booth.viaQr";
|
|
if (p.via === "card") return "booth.viaCard";
|
|
if (p.via === "plate") return "booth.viaPlate";
|
|
return null;
|
|
}
|
|
|
|
/** A short money summary for payment events (e.g. "350.00 ALL"). */
|
|
function paymentSummary(p: LedgerEvent["payload"]): string | null {
|
|
if (!p || typeof p.amountMinor !== "number" || !p.currency) return null;
|
|
return formatMoney(p.amountMinor, p.currency);
|
|
}
|
|
|
|
/** What to SHOW for an event's actor. A subscription occurrence has an opaque
|
|
* `SUBSESS-…` identity; the server resolves the holder's name into `subscriberLabel`,
|
|
* so we show that (e.g. "Aqif Kopertoni") instead. Otherwise the identity itself. */
|
|
function displayIdentity(e: LedgerEvent): string {
|
|
return e.subscriberLabel ?? e.identity ?? "—";
|
|
}
|
|
|
|
function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
|
|
const { t } = useTranslation();
|
|
const style = EVENT_STYLE[e.type];
|
|
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
|
const isAnomaly = e.type === "anomaly";
|
|
const p = e.payload;
|
|
// Localize the reason from the signed reasonCode (falls back to the English text on
|
|
// legacy events). Anomalies ALWAYS get a detail line so a red flag is never silent.
|
|
const reason = renderReason(p, t);
|
|
const amount = paymentSummary(p);
|
|
const badges = eventBadges(p);
|
|
const via = viaKey(p);
|
|
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
|
|
const showDetail = detail != null || badges.length > 0 || via != null;
|
|
|
|
// The whole row is a button → opens the event-detail modal (full payload + the
|
|
// session's entry/exit snapshots). A grid keeps the time/label/identity/index
|
|
// columns aligned across rows; the detail line lives in its own row, indented to
|
|
// start under the identity column so it never collides with the ticket code.
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpen(e)}
|
|
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
|
|
isAnomaly ? "bg-term-red/5" : ""
|
|
}`}
|
|
>
|
|
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
|
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
|
{e.plate && (
|
|
<span
|
|
className="shrink-0 rounded border border-term-border px-1 text-[11px] font-semibold tracking-wide text-term-amber"
|
|
title={t("booth.plateTitle")}
|
|
>
|
|
{e.plate}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="text-term-muted">#{e.index}</span>
|
|
{showDetail && (
|
|
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
{badges.map((k) => (
|
|
<span
|
|
key={k}
|
|
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
|
>
|
|
{t(k)}
|
|
</span>
|
|
))}
|
|
{via && (
|
|
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
|
|
{t(via)}
|
|
</span>
|
|
)}
|
|
{detail && (
|
|
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/** One label/value line in the event-detail modal. */
|
|
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
|
return (
|
|
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[12px]">
|
|
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
|
|
<span className="min-w-0 break-words text-term-text">{children}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Full read-only detail for one ledger event: business fields + the human-readable
|
|
* reason + the session's entry/exit snapshots, then the signed-chain provenance
|
|
* (signature/prev-hash/key) for an audit trail. Read-only — the ledger is immutable;
|
|
* this only DISPLAYS the signed record. */
|
|
function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) {
|
|
const { t } = useTranslation();
|
|
const style = EVENT_STYLE[e.type];
|
|
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
|
const p = e.payload;
|
|
const reason = renderReason(p, t);
|
|
const amount = paymentSummary(p);
|
|
const badges = eventBadges(p);
|
|
const isAnomaly = e.type === "anomaly";
|
|
|
|
// Pretty money for any minor-unit amount in the payload.
|
|
const money =
|
|
p && typeof p.amountMinor === "number" && typeof p.currency === "string"
|
|
? formatMoney(p.amountMinor, p.currency)
|
|
: null;
|
|
// Pull out the business fields worth a labelled row. Everything else (and the raw
|
|
// bytes) lives behind the audit disclosure — the operator sees a clean summary.
|
|
const sessionRef = typeof p?.sessionRef === "string" ? p.sessionRef : null;
|
|
const plate = typeof p?.plate === "string" ? p.plate : null;
|
|
const category = typeof p?.category === "string" ? p.category : null;
|
|
const operator = typeof p?.operator === "string" ? p.operator : null;
|
|
const tariffVersionId = typeof p?.tariffVersionId === "string" ? p.tariffVersionId : null;
|
|
|
|
return (
|
|
<Modal open onClose={onClose} title={t("booth.eventDetail")} width="max-w-2xl">
|
|
<div className="flex flex-col gap-3">
|
|
{/* Headline: the type + localized reason, prominent for anomalies. */}
|
|
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
|
|
<div className={`text-sm font-bold uppercase tracking-widest ${style?.color ?? "text-term-text"}`}>{label}</div>
|
|
{(reason || money) && (
|
|
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
|
{reason ?? money}
|
|
</div>
|
|
)}
|
|
{!reason && !money && isAnomaly && (
|
|
<div className="mt-1 text-[13px] text-term-red/90">{t("booth.evtNoReason")}</div>
|
|
)}
|
|
{badges.length > 0 && (
|
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
{badges.map((k) => (
|
|
<span
|
|
key={k}
|
|
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
|
>
|
|
{t(k)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
|
|
<div>
|
|
<DetailRow label={t("booth.edTime")}>{new Date(e.occurredAt).toLocaleString()}</DetailRow>
|
|
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
|
|
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
|
|
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
|
|
<DetailRow label={t("booth.edIdentity")}>{displayIdentity(e)}</DetailRow>
|
|
{/* When we showed a subscriber NAME above, also expose the raw occurrence id
|
|
(the SUBSESS-… session key) for traceability against the ledger. */}
|
|
{e.subscriberLabel && e.identity && (
|
|
<DetailRow label={t("booth.edOccurrence")}>
|
|
<code className="text-[11px] text-term-muted">{e.identity}</code>
|
|
</DetailRow>
|
|
)}
|
|
{money && (
|
|
<DetailRow label={t("booth.edAmount")}>
|
|
<span className="text-term-cyan">{money}</span>
|
|
</DetailRow>
|
|
)}
|
|
{typeof p?.tender === "string" && <DetailRow label={t("booth.edTender")}>{p.tender}</DetailRow>}
|
|
{viaKey(p) && (
|
|
<DetailRow label={t("booth.edVia")}>
|
|
<span className="text-term-cyan">{t(viaKey(p)!)}</span>
|
|
</DetailRow>
|
|
)}
|
|
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
|
|
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
|
|
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
|
|
{sessionRef && sessionRef !== e.identity && (
|
|
<DetailRow label={t("booth.edSession")}>{sessionRef}</DetailRow>
|
|
)}
|
|
{tariffVersionId && (
|
|
<DetailRow label={t("booth.edTariffVersion")}>
|
|
<code className="text-[11px] text-term-muted">{tariffVersionId}</code>
|
|
</DetailRow>
|
|
)}
|
|
</div>
|
|
|
|
{/* The entry/exit evidence images for this session's identity. */}
|
|
{e.identity && (
|
|
<div>
|
|
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
|
|
<SnapshotStrip identity={e.identity} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Audit data — collapsed by default. The signed-chain provenance (signature,
|
|
key, prev-hash) and the raw payload are an auditor's concern, not the
|
|
operator's; tucking them behind a disclosure keeps the common view clean
|
|
while preserving the tamper-evidence trail on demand. */}
|
|
<details className="rounded-term border border-term-border bg-term-panel-2">
|
|
<summary className="cursor-pointer select-none px-3 py-2 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">
|
|
{t("booth.edAuditData")}
|
|
</summary>
|
|
<div className="border-t border-term-border px-3 pb-3 pt-1">
|
|
<DetailRow label={t("booth.edSignature")}>
|
|
<code className="break-all text-[11px] text-term-muted">{e.signature}</code>
|
|
</DetailRow>
|
|
<DetailRow label={t("booth.edKeyId")}>
|
|
<code className="text-[11px] text-term-muted">{e.keyId}</code>
|
|
</DetailRow>
|
|
<DetailRow label={t("booth.edPrevHash")}>
|
|
<code className="break-all text-[11px] text-term-muted">{e.prevHash ?? "—"}</code>
|
|
</DetailRow>
|
|
<div className="mb-1.5 mt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
|
{t("booth.edRawPayload")}
|
|
</div>
|
|
{p && Object.keys(p).length > 0 ? (
|
|
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[11px] text-term-text">
|
|
{JSON.stringify(p, null, 2)}
|
|
</pre>
|
|
) : (
|
|
<div className="text-[12px] text-term-muted">{t("booth.edNoPayload")}</div>
|
|
)}
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
/** 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>
|
|
);
|
|
}
|
|
|
|
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);
|
|
|
|
// 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. */}
|
|
<div className="lg:col-span-2">
|
|
<Panel title={t("booth.processTicket")}>
|
|
<TicketInput onSubmit={setActiveTicket} />
|
|
</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>
|
|
);
|
|
}
|