import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js"; import { qk } from "./lib/query.js"; import { useShift } from "./lib/use-shift.js"; import { 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), // - "Open barrier" (PAID transient sessions only) → an audited human-intervention // re-pulse for a car that paid but whose barrier didn't confirm. // No payment → no Open barrier button (the no-unpaid-bypass rule). Subscriptions get // NO inline open here — their assist-open / window-charge payment is modal-only, so // the list can't one-click past an unpaid out-of-window charge. // // 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 qc = useQueryClient(); // The audited barrier re-open is a money-path action (server-gated on an open // shift); disable it unless this operator's shift is open. const { isOpen: shiftOpen, isMine: shiftMine } = useShift(); const shiftReady = shiftOpen && shiftMine; 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, }); const reopen = useMutation({ mutationFn: (identity: string) => reopenBarrier(identity), onSettled: () => { void qc.invalidateQueries({ queryKey: qk.activeSessions }); void qc.invalidateQueries({ queryKey: qk.events }); }, }); const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null); // 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") }, ]; async function handleReopen(s: ActiveSession) { setReopenMsg(null); try { const r = await reopen.mutateAsync(s.identity); setReopenMsg({ id: s.identity, ok: r.opened, text: r.opened ? t("booth.barrierOpened") : r.reason ?? t("booth.openManually"), }); } catch (e) { setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message }); } } 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 · action). 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); the trailing cell holds the audited Open-barrier action. {filtered.map((s) => { const msg = reopenMsg?.id === s.identity ? reopenMsg : null; // Paid-and-in-grace TRANSIENT only: an audited re-pulse for a car that paid // but the barrier didn't confirm. NOT overstay (owes a top-up → modal) and // NOT a subscription (assist-open lives in the modal). An unpaid transient // gets no button (no-unpaid-bypass). Mirrors reopenBarrier's server guard. const canReopen = s.paidAt && !s.overstay && !s.subscription; return ( onPick(s.identity)} className={`cursor-pointer border-t border-term-border/50 hover:bg-term-panel-2 ${ s.overstay ? "bg-term-red/5" : "" }`} title={t("booth.openPayExit")} > ); })}
{t("booth.colWho")} {t("booth.colPlate")} {t("booth.colEntry")} {t("booth.colElapsed")}
{s.subscription ? ( ★ {s.subscriptionHolder ?? t("subs.unnamed")} ) : ( s.identity )} {s.plate && ( {s.plate} )} {formatRelativeDateTime(s.enteredAt, t)} {formatDuration(s.enteredAt, new Date().toISOString())} {canReopen && ( )} {msg && ( {msg.text} )}
)}
); }