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
+15 -3
View File
@@ -98,6 +98,11 @@ export interface SessionLookup {
/** Amount owed right now (the quote). Null when no session / no active tariff. */
readonly amountMinor: number | null;
readonly currency: string | null;
/** Amount actually PAID (from the latest payment event), if any. Distinct from
* `amountMinor` (what's owed now): once a transient is settled `amountMinor` is null,
* but the operator still wants to see the sum that was collected. */
readonly paidMinor: number | null;
readonly paidCurrency: string | null;
/** True when paid AND still within the walk-back grace window. */
readonly withinGrace: boolean;
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
@@ -274,7 +279,8 @@ export class PayStation {
if (!entry) {
return {
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null,
paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
withinGrace: false, graceExpiresAt: null,
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
};
}
@@ -289,11 +295,17 @@ export class PayStation {
let paidAt: string | null = null;
let graceExitMin: number | null = null;
let paidMinor: number | null = null;
let paidCurrency: string | null = null;
for (const r of rows) {
if (r.type === "payment") {
paidAt = r.occurredAt;
const p = (r.payload ?? {}) as { graceExitMin?: number };
const p = (r.payload ?? {}) as { graceExitMin?: number; amountMinor?: number; currency?: string };
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
// Sum payments (overstay top-ups append a second one) so the displayed paid total
// reflects everything collected for the session, not just the last slip.
if (typeof p.amountMinor === "number") paidMinor = (paidMinor ?? 0) + p.amountMinor;
if (typeof p.currency === "string") paidCurrency = p.currency;
}
}
const graceExpiresAt =
@@ -328,7 +340,7 @@ export class PayStation {
return {
identity: id, found: true, open,
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay,
paidAt, amountMinor, currency, paidMinor, paidCurrency, withinGrace, graceExpiresAt, overstay,
subscription: isSubscription, subscriptionId,
subscriptionHolder: this.#holderOf(subscriptionId),
plate: plateForIdentity(this.#db, id)?.plate ?? null,