61de1fe772
Move the audited barrier re-open out of the inline Active-Sessions row button and into the modal, and turn the modal's dead-ends into useful views. - Remove the inline per-row "Open barrier" button. Clicking a row opens the modal, which carries the action. - Modal recognizes a closed-within-grace transient (found && !open && withinGrace) and shows the session view + Open barrier instead of dead-ending on "already closed" — the exact case (paid, barrier unconfirmed) that needs a re-pulse. Server reopenBarrier guard unchanged. - Active-Sessions rows show a live grace-remaining countdown badge (exited - M:SS, 1s tick off graceExpiresAt) via new formatCountdown helper. - Settled sessions show the ACTUAL sum paid (new SessionLookup.paidMinor, summed across payment events) instead of a flat "PAID" badge. - A fully-closed (grace-expired) session's modal is no longer a dead-end: it shows a read-only review view (figures + paid amount + entry/exit snapshot strip) for dispute/audit review, with no pay/exit/open controls. i18n sq+en parity kept; web build/lint/test green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
173 lines
8.6 KiB
TypeScript
173 lines
8.6 KiB
TypeScript
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<KindFilter | "">("");
|
|
|
|
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<KindFilter>[] = [
|
|
{ value: "transient", label: t("booth.fKindTransient") },
|
|
{ value: "subscription", label: t("booth.fKindSubscription") },
|
|
];
|
|
|
|
return (
|
|
<Panel
|
|
title={t("booth.activeSessions")}
|
|
right={
|
|
<span className="text-[0.625rem] uppercase tracking-wider text-term-muted">
|
|
{filtered.length}
|
|
{filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")}
|
|
</span>
|
|
}
|
|
className="min-h-0 flex-1"
|
|
>
|
|
<div className="flex h-full flex-col">
|
|
<FilterBar search={search} onSearch={setSearch} searchPlaceholder={t("booth.filterSearchSessions")}>
|
|
<SegGroup value={kind} options={kindOpts} onChange={setKind} allLabel={t("booth.filterAll")} />
|
|
</FilterBar>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
|
{filtered.length === 0 ? (
|
|
<div className="text-term-muted">
|
|
{isLoading
|
|
? t("common.loading")
|
|
: sessions.length === 0
|
|
? t("booth.noActiveSessions")
|
|
: t("booth.noMatch")}
|
|
</div>
|
|
) : (
|
|
// 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).
|
|
<table className="w-full text-[0.75rem] tabular-nums">
|
|
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
|
<tr>
|
|
<th className="px-2 py-1.5 text-left font-semibold">{t("booth.colWho")}</th>
|
|
<th className="px-2 py-1.5 text-left font-semibold">{t("booth.colPlate")}</th>
|
|
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colEntry")}</th>
|
|
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colElapsed")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{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 (
|
|
<tr
|
|
key={s.identity}
|
|
onClick={() => 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")}
|
|
>
|
|
<td className="px-2 py-1.5 text-term-text">
|
|
{s.subscription ? (
|
|
<span className="text-term-cyan">★ {s.subscriptionHolder ?? t("subs.unnamed")}</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1.5">
|
|
{s.identity}
|
|
{closedInGrace && (
|
|
<span
|
|
className="rounded border border-term-amber/60 px-1 text-[0.5625rem] uppercase tracking-wider tabular-nums text-term-amber"
|
|
title={t("booth.exitedGraceTitle")}
|
|
>
|
|
{graceLeft ? t("booth.exitedGraceLeft", { time: graceLeft }) : t("booth.exitedGrace")}
|
|
</span>
|
|
)}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-2 py-1.5">
|
|
{s.plate && (
|
|
<span
|
|
className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber"
|
|
title={t("booth.plateTitle")}
|
|
>
|
|
{s.plate}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
|
|
{formatRelativeDateTime(s.enteredAt, t)}
|
|
</td>
|
|
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
|
|
{/* Freeze the elapsed at the recorded exit for a closed-in-grace row. */}
|
|
{formatDuration(s.enteredAt, (closedInGrace ? s.exitedAt : null) ?? new Date().toISOString())}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
);
|
|
}
|