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:
@@ -73,6 +73,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
// exit. A normal within-grace paid session is NOT payable (it's settled). See
|
||||
// booth-exit-flow.md / reopenBarrier server guard.
|
||||
const isOverstay = s?.overstay === true;
|
||||
// CLOSED-WITHIN-GRACE: a paid transient whose exit was already signed but the barrier
|
||||
// didn't confirm — it lingers in the active list until grace runs out (the "phantom
|
||||
// re-close" / damaged-ticket case). `s.open` is false, so it's not payable and not the
|
||||
// normal review flow; the only action is an audited manual re-pulse of the barrier.
|
||||
// (A grace-EXPIRED closed session falls through to the plain "already closed" notice.)
|
||||
const closedWithinGrace = !!(s?.found && !s.open && s.withinGrace && !isSubscription);
|
||||
// A subscription is normally prepaid (never charged). EXCEPTION: a time-window plan can
|
||||
// owe an out-of-window TARIFF-BRIDGE charge (early entry / late exit) — lookup() returns
|
||||
// it as s.amountMinor, and exit is GATED until it's paid. So a subscription IS payable
|
||||
@@ -278,21 +284,54 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s && s.found && !s.open && (
|
||||
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||||
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
|
||||
</div>
|
||||
{s && s.found && !s.open && !closedWithinGrace && (
|
||||
// A fully-closed session (exited, grace expired): no action to take, but the
|
||||
// operator may still need to REVIEW the evidence (entry/exit snapshots + plate)
|
||||
// — e.g. a dispute about a car that just left. Show the closed notice, the
|
||||
// figures, and the snapshot strip read-only. No tender / voucher / open here.
|
||||
<>
|
||||
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||||
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
|
||||
<Row label={t("pay.exit")} value={formatTime(s.exitedAt)} />
|
||||
<Row
|
||||
label={t("pay.duration")}
|
||||
value={
|
||||
s.enteredAt ? formatDuration(s.enteredAt, s.exitedAt ?? new Date().toISOString()) : "—"
|
||||
}
|
||||
/>
|
||||
{alreadyPaid && s.paidMinor != null && s.paidCurrency && (
|
||||
<Row label={t("pay.paidAmount")} value={formatMoney(s.paidMinor, s.paidCurrency)} valueClass="text-term-green" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SnapshotStrip identity={identity} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{s && s.found && s.open && (
|
||||
{s && s.found && (s.open || closedWithinGrace) && (
|
||||
<>
|
||||
{/* Session figures */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
|
||||
<Row label={t("pay.now")} value={formatTime(new Date().toISOString())} />
|
||||
{/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
|
||||
<Row
|
||||
label={closedWithinGrace ? t("pay.exit") : t("pay.now")}
|
||||
value={closedWithinGrace ? formatTime(s.exitedAt) : formatTime(new Date().toISOString())}
|
||||
/>
|
||||
<Row
|
||||
label={t("pay.duration")}
|
||||
value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"}
|
||||
value={
|
||||
s.enteredAt
|
||||
? formatDuration(
|
||||
s.enteredAt,
|
||||
(closedWithinGrace ? s.exitedAt : null) ?? new Date().toISOString(),
|
||||
)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label={t("pay.statusLabel")}
|
||||
@@ -301,18 +340,22 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
? t("pay.subscription")
|
||||
: isOverstay
|
||||
? t("pay.overstay")
|
||||
: alreadyPaid
|
||||
? t("pay.paid")
|
||||
: t("pay.unpaid")
|
||||
: closedWithinGrace
|
||||
? t("pay.closedWithinGrace")
|
||||
: alreadyPaid
|
||||
? t("pay.paid")
|
||||
: t("pay.unpaid")
|
||||
}
|
||||
valueClass={
|
||||
isSubscription
|
||||
? "text-term-cyan"
|
||||
: isOverstay
|
||||
? "text-term-red"
|
||||
: alreadyPaid
|
||||
? "text-term-green"
|
||||
: "text-term-amber"
|
||||
: closedWithinGrace
|
||||
? "text-term-amber"
|
||||
: alreadyPaid
|
||||
? "text-term-green"
|
||||
: "text-term-amber"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -322,7 +365,16 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
amount is the TOP-UP delta, not the whole stay. */}
|
||||
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
|
||||
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
{subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")}
|
||||
{subWindowDue
|
||||
? t("pay.windowCharge")
|
||||
: isSubscription
|
||||
? t("pay.plan")
|
||||
: isOverstay
|
||||
? t("pay.topUp")
|
||||
: alreadyPaid && s.paidMinor != null
|
||||
? // Settled session — the figure is the sum collected, not a quote.
|
||||
t("pay.paidAmount")
|
||||
: t("pay.total")}
|
||||
</span>
|
||||
<span className="text-3xl font-bold text-term-cyan">
|
||||
{subWindowDue && s.amountMinor != null && s.currency
|
||||
@@ -331,9 +383,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
? t("pay.prepaid")
|
||||
: s.amountMinor != null && s.currency
|
||||
? formatMoney(s.amountMinor, s.currency)
|
||||
: alreadyPaid
|
||||
? t("booth.badgePaid")
|
||||
: t("pay.noTariff")}
|
||||
: alreadyPaid && s.paidMinor != null && s.paidCurrency
|
||||
? // Settled (within-grace / closed): show the sum actually collected.
|
||||
formatMoney(s.paidMinor, s.paidCurrency)
|
||||
: alreadyPaid
|
||||
? t("booth.badgePaid")
|
||||
: t("pay.noTariff")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -361,6 +416,14 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Closed-within-grace: the exit is already paid + recorded; the barrier
|
||||
just didn't confirm. Explain that the only action is a manual re-pulse. */}
|
||||
{closedWithinGrace && (
|
||||
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||||
{t("pay.closedWithinGraceHint")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Snapshots */}
|
||||
<SnapshotStrip identity={identity} />
|
||||
|
||||
@@ -382,8 +445,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */}
|
||||
{phase !== "done" && !isSubscription && (
|
||||
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). Not
|
||||
for a closed-within-grace session — its exit is already recorded. */}
|
||||
{phase !== "done" && !isSubscription && !closedWithinGrace && (
|
||||
<label className="flex items-center gap-2 text-[0.75rem]">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -464,7 +528,19 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
{isSubscription ? (
|
||||
{closedWithinGrace ? (
|
||||
// Paid + exited but the barrier didn't confirm — the only action is
|
||||
// an audited manual re-pulse (the server re-opens without signing a
|
||||
// second exit). No payment, no voucher; mirrors reopenBarrier's guard.
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenBarrier}
|
||||
disabled={!shiftReady || phase === "finishing"}
|
||||
className="btn btn-pay btn-lg"
|
||||
>
|
||||
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
||||
</button>
|
||||
) : isSubscription ? (
|
||||
subWindowDue && !windowPaid ? (
|
||||
// Step 1 — a window charge is owed: take payment first. The
|
||||
// barrier open is the explicit next step (revealed once paid).
|
||||
|
||||
Reference in New Issue
Block a user