feat(booth): rework Active Sessions + pay/exit modal around barrier re-open

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
This commit is contained in:
2026-06-30 17:58:24 +02:00
parent cfac14e09e
commit 61de1fe772
7 changed files with 194 additions and 105 deletions
+42 -76
View File
@@ -1,10 +1,9 @@
import { useMemo, useState } from "react";
import { useEffect, 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 { useQuery } from "@tanstack/react-query";
import { fetchActiveSessions } from "./api.js";
import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js";
import { formatDuration, formatRelativeDateTime } from "./lib/format.js";
import { formatCountdown, formatDuration, formatRelativeDateTime } from "./lib/format.js";
import { Panel } from "./ui/Panel.js";
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
@@ -12,13 +11,8 @@ import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
// 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.
// 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
@@ -30,11 +24,6 @@ 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,
@@ -43,14 +32,13 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
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);
// 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 ★.)
@@ -77,20 +65,6 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
{ 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 (
<Panel
title={t("booth.activeSessions")}
@@ -117,11 +91,11 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
: t("booth.noMatch")}
</div>
) : (
// 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.
// 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>
@@ -129,31 +103,43 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
<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>
<th className="px-2 py-1.5" />
</tr>
</thead>
<tbody>
{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;
// 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" : ""
s.overstay ? "bg-term-red/5" : closedInGrace ? "bg-term-amber/5 text-term-muted" : ""
}`}
title={t("booth.openPayExit")}
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>
) : (
s.identity
<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">
@@ -170,28 +156,8 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
{formatRelativeDateTime(s.enteredAt, t)}
</td>
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
{formatDuration(s.enteredAt, new Date().toISOString())}
</td>
<td className="px-2 py-1.5 text-right">
{canReopen && (
<button
type="button"
disabled={reopen.isPending || !shiftReady}
onClick={(e) => {
e.stopPropagation(); // don't also open the pay/exit modal
void handleReopen(s);
}}
className="btn btn-pay btn-sm"
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
>
{t("booth.openBarrier")}
</button>
)}
{msg && (
<span className={`ml-2 text-[0.625rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
{msg.text}
</span>
)}
{/* 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>
);