import { 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"; // 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, or review), // - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse. // No payment → no Open barrier button (the no-unpaid-bypass rule). // See wiki/concepts/booth-exit-flow.md. function statusBadge(s: ActiveSession): { key: string; cls: string } { if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" }; if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" }; if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" }; return { key: "booth.badgeUnpaid", cls: "text-term-amber" }; } 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); const sessions = data?.sessions ?? []; 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 ( {sessions.length} {t("booth.insideCount")} } className="min-h-0" >
{sessions.length === 0 ? (
{isLoading ? t("common.loading") : t("booth.noActiveSessions")}
) : ( sessions.map((s) => { const badge = statusBadge(s); const msg = reopenMsg?.id === s.identity ? reopenMsg : null; return (
{/* Open barrier — PAID transient OR a SUBSCRIPTION (prepaid). An unpaid transient has no button (no-unpaid-bypass). */} {s.paidAt || s.subscription ? ( ) : ( )} {msg && ( {msg.text} )}
); }) )}
); }