import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { fetchActiveSessions } from "./api.js"; import { qk } from "./lib/query.js"; import { formatCountdown, formatDuration, formatRelativeDateTime } from "./lib/format.js"; import { Panel } from "./ui/Panel.js"; import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js"; // Active Sessions panel. A session is "active" while still inside OR exited-but- // within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed // possibly-present until grace runs out). Lets the operator find a stuck car — // damaged ticket, dead scanner, or a phantom barrier re-close — without a scan: // click a row → the pay/exit modal (pay an unpaid car, settle a subscriber's // out-of-window charge, assist-open a prepaid subscriber, or review). // // OVERSTAY sessions (paid, grace expired, no signed exit) are no longer aged out — they // stay listed with a distinct badge. A new period has begun (the car re-parked or is // faulty/abandoned); occupancy lingers and the car owes a fresh top-up. The operator // reconciles via the pay/exit modal — never a free barrier open. // See wiki/concepts/booth-exit-flow.md. type KindFilter = "transient" | "subscription"; export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) { const { t } = useTranslation(); const { data, isLoading } = useQuery({ queryKey: qk.activeSessions, queryFn: fetchActiveSessions, // Belt-and-braces refresh in case a grace window expires with no ledger event // to invalidate the cache (the WS only pushes on appends). refetchInterval: 15_000, }); // A 1-second clock so the within-grace countdown badge ticks live (the query only // refetches every 15s; the badge needs per-second resolution). const [nowMs, setNowMs] = useState(() => Date.now()); useEffect(() => { const id = setInterval(() => setNowMs(Date.now()), 1000); return () => clearInterval(id); }, []); // Filters: free-text search + transient-vs-subscriber. (No status filter — the status // column was dropped; an unpaid transient is normal and a subscriber is marked ★.) const [search, setSearch] = useState(""); const [kind, setKind] = useState(""); const sessions = useMemo(() => data?.sessions ?? [], [data]); const filtered = useMemo(() => { const q = search.trim().toLowerCase(); return sessions.filter((s) => { if (kind === "transient" && s.subscription) return false; if (kind === "subscription" && !s.subscription) return false; if (q) { // Include the enriched plate (`s.plate`, the displayed badge) so a plate search hits. const hay = `${s.identity} ${s.subscriptionHolder ?? ""} ${s.plate ?? ""}`.toLowerCase(); if (!hay.includes(q)) return false; } return true; }); }, [sessions, search, kind]); const kindOpts: SegOption[] = [ { value: "transient", label: t("booth.fKindTransient") }, { value: "subscription", label: t("booth.fKindSubscription") }, ]; return ( {filtered.length} {filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")} } className="min-h-0 flex-1" >
{filtered.length === 0 ? (
{isLoading ? t("common.loading") : sessions.length === 0 ? t("booth.noActiveSessions") : t("booth.noMatch")}
) : ( // A real table — aligned columns (who · plate · entry · elapsed). No status // column: an unpaid transient is the normal case, and a subscriber is already // marked with ★ + holder name. Overstay (a top-up is owed) keeps a row tint so // that fraud-relevant signal isn't lost. The whole row is clickable (→ pay/exit // modal). {filtered.map((s) => { // EXITED-WITHIN-GRACE: a paid transient whose exit is recorded but the // barrier didn't confirm — it lingers here until grace runs out. Mark it // so the operator can tell it apart from a still-inside car (clicking it // opens the modal's manual barrier re-open, not a pay flow). const closedInGrace = !s.open && s.withinGrace && !s.subscription; // Live grace-remaining for the badge (M:SS). Null once it lapses — the // next refetch (≤15s) reclassifies the row (overstay / gone); until then // we show a generic label so the badge doesn't flicker empty. const graceLeft = closedInGrace ? formatCountdown(s.graceExpiresAt, nowMs) : null; return ( onPick(s.identity)} className={`cursor-pointer border-t border-term-border/50 hover:bg-term-panel-2 ${ s.overstay ? "bg-term-red/5" : closedInGrace ? "bg-term-amber/5 text-term-muted" : "" }`} title={closedInGrace ? t("booth.openReopenBarrier") : t("booth.openPayExit")} > ); })}
{t("booth.colWho")} {t("booth.colPlate")} {t("booth.colEntry")} {t("booth.colElapsed")}
{s.subscription ? ( ★ {s.subscriptionHolder ?? t("subs.unnamed")} ) : ( {s.identity} {closedInGrace && ( {graceLeft ? t("booth.exitedGraceLeft", { time: graceLeft }) : t("booth.exitedGrace")} )} )} {s.plate && ( {s.plate} )} {formatRelativeDateTime(s.enteredAt, t)} {/* Freeze the elapsed at the recorded exit for a closed-in-grace row. */} {formatDuration(s.enteredAt, (closedInGrace ? s.exitedAt : null) ?? new Date().toISOString())}
)}
); }