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:
@@ -98,6 +98,11 @@ export interface SessionLookup {
|
|||||||
/** Amount owed right now (the quote). Null when no session / no active tariff. */
|
/** Amount owed right now (the quote). Null when no session / no active tariff. */
|
||||||
readonly amountMinor: number | null;
|
readonly amountMinor: number | null;
|
||||||
readonly currency: string | 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. */
|
/** True when paid AND still within the walk-back grace window. */
|
||||||
readonly withinGrace: boolean;
|
readonly withinGrace: boolean;
|
||||||
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
|
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
|
||||||
@@ -274,7 +279,8 @@ export class PayStation {
|
|||||||
if (!entry) {
|
if (!entry) {
|
||||||
return {
|
return {
|
||||||
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
|
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,
|
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -289,11 +295,17 @@ export class PayStation {
|
|||||||
|
|
||||||
let paidAt: string | null = null;
|
let paidAt: string | null = null;
|
||||||
let graceExitMin: number | null = null;
|
let graceExitMin: number | null = null;
|
||||||
|
let paidMinor: number | null = null;
|
||||||
|
let paidCurrency: string | null = null;
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (r.type === "payment") {
|
if (r.type === "payment") {
|
||||||
paidAt = r.occurredAt;
|
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;
|
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 =
|
const graceExpiresAt =
|
||||||
@@ -328,7 +340,7 @@ export class PayStation {
|
|||||||
return {
|
return {
|
||||||
identity: id, found: true, open,
|
identity: id, found: true, open,
|
||||||
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
|
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
|
||||||
paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay,
|
paidAt, amountMinor, currency, paidMinor, paidCurrency, withinGrace, graceExpiresAt, overstay,
|
||||||
subscription: isSubscription, subscriptionId,
|
subscription: isSubscription, subscriptionId,
|
||||||
subscriptionHolder: this.#holderOf(subscriptionId),
|
subscriptionHolder: this.#holderOf(subscriptionId),
|
||||||
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
|
import { fetchActiveSessions } from "./api.js";
|
||||||
import { qk } from "./lib/query.js";
|
import { qk } from "./lib/query.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { formatCountdown, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||||
import { formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
|
||||||
import { Panel } from "./ui/Panel.js";
|
import { Panel } from "./ui/Panel.js";
|
||||||
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.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
|
// 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 —
|
// 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:
|
// 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
|
// 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),
|
// 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.
|
|
||||||
//
|
//
|
||||||
// OVERSTAY sessions (paid, grace expired, no signed exit) are no longer aged out — they
|
// 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
|
// 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 }) {
|
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
|
||||||
const { t } = useTranslation();
|
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({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: qk.activeSessions,
|
queryKey: qk.activeSessions,
|
||||||
queryFn: fetchActiveSessions,
|
queryFn: fetchActiveSessions,
|
||||||
@@ -43,14 +32,13 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const reopen = useMutation({
|
// A 1-second clock so the within-grace countdown badge ticks live (the query only
|
||||||
mutationFn: (identity: string) => reopenBarrier(identity),
|
// refetches every 15s; the badge needs per-second resolution).
|
||||||
onSettled: () => {
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
useEffect(() => {
|
||||||
void qc.invalidateQueries({ queryKey: qk.events });
|
const id = setInterval(() => setNowMs(Date.now()), 1000);
|
||||||
},
|
return () => clearInterval(id);
|
||||||
});
|
}, []);
|
||||||
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
|
|
||||||
|
|
||||||
// Filters: free-text search + transient-vs-subscriber. (No status filter — the status
|
// 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 ★.)
|
// 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") },
|
{ 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 (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
title={t("booth.activeSessions")}
|
title={t("booth.activeSessions")}
|
||||||
@@ -117,11 +91,11 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
: t("booth.noMatch")}
|
: t("booth.noMatch")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// A real table — aligned columns (who · plate · entry · elapsed · action). No
|
// A real table — aligned columns (who · plate · entry · elapsed). No status
|
||||||
// status column: an unpaid transient is the normal case, and a subscriber is
|
// column: an unpaid transient is the normal case, and a subscriber is already
|
||||||
// already marked with ★ + holder name. Overstay (a top-up is owed) keeps a row
|
// marked with ★ + holder name. Overstay (a top-up is owed) keeps a row tint so
|
||||||
// tint so that fraud-relevant signal isn't lost. The whole row is clickable
|
// that fraud-relevant signal isn't lost. The whole row is clickable (→ pay/exit
|
||||||
// (→ pay/exit modal); the trailing cell holds the audited Open-barrier action.
|
// modal).
|
||||||
<table className="w-full text-[0.75rem] tabular-nums">
|
<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">
|
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
<tr>
|
<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="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.colEntry")}</th>
|
||||||
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colElapsed")}</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered.map((s) => {
|
{filtered.map((s) => {
|
||||||
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
|
// EXITED-WITHIN-GRACE: a paid transient whose exit is recorded but the
|
||||||
// Paid-and-in-grace TRANSIENT only: an audited re-pulse for a car that paid
|
// barrier didn't confirm — it lingers here until grace runs out. Mark it
|
||||||
// but the barrier didn't confirm. NOT overstay (owes a top-up → modal) and
|
// so the operator can tell it apart from a still-inside car (clicking it
|
||||||
// NOT a subscription (assist-open lives in the modal). An unpaid transient
|
// opens the modal's manual barrier re-open, not a pay flow).
|
||||||
// gets no button (no-unpaid-bypass). Mirrors reopenBarrier's server guard.
|
const closedInGrace = !s.open && s.withinGrace && !s.subscription;
|
||||||
const canReopen = s.paidAt && !s.overstay && !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 (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={s.identity}
|
key={s.identity}
|
||||||
onClick={() => onPick(s.identity)}
|
onClick={() => onPick(s.identity)}
|
||||||
className={`cursor-pointer border-t border-term-border/50 hover:bg-term-panel-2 ${
|
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">
|
<td className="px-2 py-1.5 text-term-text">
|
||||||
{s.subscription ? (
|
{s.subscription ? (
|
||||||
<span className="text-term-cyan">★ {s.subscriptionHolder ?? t("subs.unnamed")}</span>
|
<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>
|
||||||
<td className="px-2 py-1.5">
|
<td className="px-2 py-1.5">
|
||||||
@@ -170,28 +156,8 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
{formatRelativeDateTime(s.enteredAt, t)}
|
{formatRelativeDateTime(s.enteredAt, t)}
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
|
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
|
||||||
{formatDuration(s.enteredAt, new Date().toISOString())}
|
{/* Freeze the elapsed at the recorded exit for a closed-in-grace row. */}
|
||||||
</td>
|
{formatDuration(s.enteredAt, (closedInGrace ? s.exitedAt : null) ?? new Date().toISOString())}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
// exit. A normal within-grace paid session is NOT payable (it's settled). See
|
||||||
// booth-exit-flow.md / reopenBarrier server guard.
|
// booth-exit-flow.md / reopenBarrier server guard.
|
||||||
const isOverstay = s?.overstay === true;
|
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
|
// 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
|
// 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
|
// 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{s && s.found && !s.open && (
|
{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">
|
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||||||
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
|
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
|
||||||
</div>
|
</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 */}
|
{/* Session figures */}
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
<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.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
|
<Row
|
||||||
label={t("pay.duration")}
|
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
|
<Row
|
||||||
label={t("pay.statusLabel")}
|
label={t("pay.statusLabel")}
|
||||||
@@ -301,6 +340,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
? t("pay.subscription")
|
? t("pay.subscription")
|
||||||
: isOverstay
|
: isOverstay
|
||||||
? t("pay.overstay")
|
? t("pay.overstay")
|
||||||
|
: closedWithinGrace
|
||||||
|
? t("pay.closedWithinGrace")
|
||||||
: alreadyPaid
|
: alreadyPaid
|
||||||
? t("pay.paid")
|
? t("pay.paid")
|
||||||
: t("pay.unpaid")
|
: t("pay.unpaid")
|
||||||
@@ -310,6 +351,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
? "text-term-cyan"
|
? "text-term-cyan"
|
||||||
: isOverstay
|
: isOverstay
|
||||||
? "text-term-red"
|
? "text-term-red"
|
||||||
|
: closedWithinGrace
|
||||||
|
? "text-term-amber"
|
||||||
: alreadyPaid
|
: alreadyPaid
|
||||||
? "text-term-green"
|
? "text-term-green"
|
||||||
: "text-term-amber"
|
: "text-term-amber"
|
||||||
@@ -322,7 +365,16 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
amount is the TOP-UP delta, not the whole stay. */}
|
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">
|
<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">
|
<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>
|
||||||
<span className="text-3xl font-bold text-term-cyan">
|
<span className="text-3xl font-bold text-term-cyan">
|
||||||
{subWindowDue && s.amountMinor != null && s.currency
|
{subWindowDue && s.amountMinor != null && s.currency
|
||||||
@@ -331,6 +383,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
? t("pay.prepaid")
|
? t("pay.prepaid")
|
||||||
: s.amountMinor != null && s.currency
|
: s.amountMinor != null && s.currency
|
||||||
? formatMoney(s.amountMinor, s.currency)
|
? formatMoney(s.amountMinor, s.currency)
|
||||||
|
: alreadyPaid && s.paidMinor != null && s.paidCurrency
|
||||||
|
? // Settled (within-grace / closed): show the sum actually collected.
|
||||||
|
formatMoney(s.paidMinor, s.paidCurrency)
|
||||||
: alreadyPaid
|
: alreadyPaid
|
||||||
? t("booth.badgePaid")
|
? t("booth.badgePaid")
|
||||||
: t("pay.noTariff")}
|
: t("pay.noTariff")}
|
||||||
@@ -361,6 +416,14 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
</div>
|
</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 */}
|
{/* Snapshots */}
|
||||||
<SnapshotStrip identity={identity} />
|
<SnapshotStrip identity={identity} />
|
||||||
|
|
||||||
@@ -382,8 +445,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */}
|
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). Not
|
||||||
{phase !== "done" && !isSubscription && (
|
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]">
|
<label className="flex items-center gap-2 text-[0.75rem]">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -464,7 +528,19 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
>
|
>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</button>
|
</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 ? (
|
subWindowDue && !windowPaid ? (
|
||||||
// Step 1 — a window charge is owed: take payment first. The
|
// Step 1 — a window charge is owed: take payment first. The
|
||||||
// barrier open is the explicit next step (revealed once paid).
|
// barrier open is the explicit next step (revealed once paid).
|
||||||
|
|||||||
@@ -1168,6 +1168,9 @@ export interface SessionLookup {
|
|||||||
paidAt: string | null;
|
paidAt: string | null;
|
||||||
amountMinor: number | null;
|
amountMinor: number | null;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
|
/** Amount actually PAID (sum of payment events), independent of what's owed now. */
|
||||||
|
paidMinor: number | null;
|
||||||
|
paidCurrency: string | null;
|
||||||
withinGrace: boolean;
|
withinGrace: boolean;
|
||||||
graceExpiresAt: string | null;
|
graceExpiresAt: string | null;
|
||||||
/** OVERSTAY: paid transient, walk-back grace expired, no exit — a new period began;
|
/** OVERSTAY: paid transient, walk-back grace expired, no exit — a new period began;
|
||||||
|
|||||||
@@ -22,6 +22,22 @@ export function formatDuration(fromIso: string, toIso: string): string {
|
|||||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remaining time until `untilIso`, as a live countdown: "M:SS" (or "H:MM:SS" past an
|
||||||
|
* hour). Returns null once expired (or for a bad/empty input) so callers can drop the
|
||||||
|
* badge. Pass `nowMs` (a ticking clock) to make it update each second. */
|
||||||
|
export function formatCountdown(untilIso: string | null, nowMs: number = Date.now()): string | null {
|
||||||
|
if (!untilIso) return null;
|
||||||
|
const ms = Date.parse(untilIso) - nowMs;
|
||||||
|
if (!Number.isFinite(ms) || ms <= 0) return null;
|
||||||
|
const total = Math.ceil(ms / 1000);
|
||||||
|
const h = Math.floor(total / 3600);
|
||||||
|
const m = Math.floor((total % 3600) / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
const ss = String(s).padStart(2, "0");
|
||||||
|
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${ss}`;
|
||||||
|
return `${m}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */
|
/** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */
|
||||||
export function formatMinutes(mins: number): string {
|
export function formatMinutes(mins: number): string {
|
||||||
if (!Number.isFinite(mins) || mins < 0) return "—";
|
if (!Number.isFinite(mins) || mins < 0) return "—";
|
||||||
|
|||||||
@@ -160,6 +160,10 @@ export const en: Catalog = {
|
|||||||
fEvtVoid: "Void",
|
fEvtVoid: "Void",
|
||||||
fEvtAnomaly: "Anomaly",
|
fEvtAnomaly: "Anomaly",
|
||||||
openPayExit: "Open pay / exit",
|
openPayExit: "Open pay / exit",
|
||||||
|
openReopenBarrier: "Open — paid, awaiting barrier",
|
||||||
|
exitedGrace: "exited · grace",
|
||||||
|
exitedGraceLeft: "exited · {{time}}",
|
||||||
|
exitedGraceTitle: "Paid and exited — barrier not confirmed; waiting out the grace period.",
|
||||||
openBarrier: "Open barrier",
|
openBarrier: "Open barrier",
|
||||||
openBarrierTitle: "Human-intervention barrier open (audited)",
|
openBarrierTitle: "Human-intervention barrier open (audited)",
|
||||||
barrierOpened: "barrier opened",
|
barrierOpened: "barrier opened",
|
||||||
@@ -874,14 +878,18 @@ export const en: Catalog = {
|
|||||||
ticket: "Ticket",
|
ticket: "Ticket",
|
||||||
entry: "Entry",
|
entry: "Entry",
|
||||||
now: "Now",
|
now: "Now",
|
||||||
|
exit: "Exit",
|
||||||
duration: "Duration",
|
duration: "Duration",
|
||||||
statusLabel: "Status",
|
statusLabel: "Status",
|
||||||
paid: "PAID",
|
paid: "PAID",
|
||||||
unpaid: "UNPAID",
|
unpaid: "UNPAID",
|
||||||
overstay: "OVERSTAY",
|
overstay: "OVERSTAY",
|
||||||
overstayHint: "Earlier session paid. The customer failed to exit during the grace period. Payment for the new period is required. The total below is the new period's fee.",
|
overstayHint: "Earlier session paid. The customer failed to exit during the grace period. Payment for the new period is required. The total below is the new period's fee.",
|
||||||
|
closedWithinGrace: "EXITED · GRACE",
|
||||||
|
closedWithinGraceHint: "Paid and exit recorded — the barrier didn't confirm yet. The car stays listed until the grace period ends. Open the barrier manually if it's still waiting.",
|
||||||
topUp: "New period due",
|
topUp: "New period due",
|
||||||
total: "Total",
|
total: "Total",
|
||||||
|
paidAmount: "Paid",
|
||||||
noTariff: "no tariff",
|
noTariff: "no tariff",
|
||||||
tender: "Tender",
|
tender: "Tender",
|
||||||
cash: "Cash",
|
cash: "Cash",
|
||||||
|
|||||||
@@ -162,10 +162,14 @@ export const sq = {
|
|||||||
fEvtVoid: "Anulim",
|
fEvtVoid: "Anulim",
|
||||||
fEvtAnomaly: "Anomali",
|
fEvtAnomaly: "Anomali",
|
||||||
openPayExit: "Hap pagesën / daljen",
|
openPayExit: "Hap pagesën / daljen",
|
||||||
|
openReopenBarrier: "Hap — paguar, pret barrierën",
|
||||||
|
exitedGrace: "doli · në afat",
|
||||||
|
exitedGraceLeft: "doli · {{time}}",
|
||||||
|
exitedGraceTitle: "Paguar dhe dalur — barriera nuk u konfirmua; po pret afatin kohor.",
|
||||||
openBarrier: "Hap barrierën",
|
openBarrier: "Hap barrierën",
|
||||||
openBarrierTitle: "Hap barrierën manualisht",
|
openBarrierTitle: "Hap barrierën manualisht",
|
||||||
barrierOpened: "barriera u hap",
|
barrierOpened: "barriera u hap",
|
||||||
openManually: "hape me dorë",
|
openManually: "hape manualisht",
|
||||||
// session row badges
|
// session row badges
|
||||||
badgeExiting: "duke dalë",
|
badgeExiting: "duke dalë",
|
||||||
badgePaid: "paguar",
|
badgePaid: "paguar",
|
||||||
@@ -242,9 +246,9 @@ export const sq = {
|
|||||||
"exit.refused.noSession": "Dalja u refuzua — biletë e panjohur",
|
"exit.refused.noSession": "Dalja u refuzua — biletë e panjohur",
|
||||||
"exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)",
|
"exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)",
|
||||||
"exit.refused.graceExpired": "Dalja u refuzua — afati i daljes skadoi (kërkohet pagesë shtesë)",
|
"exit.refused.graceExpired": "Dalja u refuzua — afati i daljes skadoi (kërkohet pagesë shtesë)",
|
||||||
"exit.open.noBarrier": "Dalja u regjistrua, por nuk ka barrierë daljeje të konfiguruar — hape me dorë",
|
"exit.open.noBarrier": "Dalja u regjistrua, por nuk ka barrierë daljeje të konfiguruar — hape manualisht",
|
||||||
"exit.open.unavailable": "Dalja u regjistrua, por barriera është e padisponueshme — hape me dorë",
|
"exit.open.unavailable": "Dalja u regjistrua, por barriera është e padisponueshme — hape manualisht",
|
||||||
"exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape me dorë",
|
"exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape manualisht",
|
||||||
"exit.freeGrace": "Periudhë pa pagesë në hyrje (pa tarifë)",
|
"exit.freeGrace": "Periudhë pa pagesë në hyrje (pa tarifë)",
|
||||||
"exit.manualOpen": "Hapje manuale e barrierës (ndërhyrje njerëzore)",
|
"exit.manualOpen": "Hapje manuale e barrierës (ndërhyrje njerëzore)",
|
||||||
"sub.refused.notFound": "Abonimi u refuzua — nuk u gjet",
|
"sub.refused.notFound": "Abonimi u refuzua — nuk u gjet",
|
||||||
@@ -890,20 +894,24 @@ export const sq = {
|
|||||||
ticket: "Bileta",
|
ticket: "Bileta",
|
||||||
entry: "Hyrja",
|
entry: "Hyrja",
|
||||||
now: "Tani",
|
now: "Tani",
|
||||||
|
exit: "Dalja",
|
||||||
duration: "Kohëzgjatja",
|
duration: "Kohëzgjatja",
|
||||||
statusLabel: "Statusi",
|
statusLabel: "Statusi",
|
||||||
paid: "PAGUAR",
|
paid: "PAGUAR",
|
||||||
unpaid: "PAPAGUAR",
|
unpaid: "PAPAGUAR",
|
||||||
overstay: "TEJ AFATIT",
|
overstay: "TEJ AFATIT",
|
||||||
overstayHint: "Sesion i mëparshëm i paguar. Klienti nuk doli brënda afatit kohor. Kërkohet pagesë për periudhën e re. Totali më poshtë është tarifa e periudhës së re.",
|
overstayHint: "Sesion i mëparshëm i paguar. Klienti nuk doli brënda afatit kohor. Kërkohet pagesë për periudhën e re. Totali më poshtë është tarifa e periudhës së re.",
|
||||||
|
closedWithinGrace: "Paguar",
|
||||||
|
closedWithinGraceHint: "Pagesa dhe dalja u regjistruan — barriera nuk u konfirmua ende. Makina mbetet në listë derisa të mbarojë afati. Hapni barrierën manualisht nëse pret ende.",
|
||||||
topUp: "Periudha e re për pagesë",
|
topUp: "Periudha e re për pagesë",
|
||||||
total: "Totali",
|
total: "Totali",
|
||||||
|
paidAmount: "Paguar",
|
||||||
noTariff: "pa tarifë",
|
noTariff: "pa tarifë",
|
||||||
tender: "Mënyra",
|
tender: "Mënyra",
|
||||||
cash: "Para",
|
cash: "Para",
|
||||||
card: "Kartë",
|
card: "Kartë",
|
||||||
printExitVoucher: "Printo biletë dalje",
|
printExitVoucher: "Printo biletë dalje",
|
||||||
selfExitHint: "(klienti del vetë te dalja)",
|
selfExitHint: "(klienti del duke skanuar biletën)",
|
||||||
payAndOpen: "Paguaj + hap barrierën",
|
payAndOpen: "Paguaj + hap barrierën",
|
||||||
payAndVoucher: "Paguaj + printo biletën",
|
payAndVoucher: "Paguaj + printo biletën",
|
||||||
openBarrier: "Hap barrierën",
|
openBarrier: "Hap barrierën",
|
||||||
@@ -926,7 +934,7 @@ export const sq = {
|
|||||||
windowCharge: "JASHTË ORARIT",
|
windowCharge: "JASHTË ORARIT",
|
||||||
windowChargeHint: "Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën, pastaj hap barrierën.",
|
windowChargeHint: "Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën, pastaj hap barrierën.",
|
||||||
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
|
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
|
||||||
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
|
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del duke skanuar biletën.",
|
||||||
// payment receipt (transparency slip)
|
// payment receipt (transparency slip)
|
||||||
receiptPrintFailed: "(fatura nuk u printua — provoni \"Riprinto faturën\".)",
|
receiptPrintFailed: "(fatura nuk u printua — provoni \"Riprinto faturën\".)",
|
||||||
receiptReprinted: "Fatura u riprintua në {{printer}}.",
|
receiptReprinted: "Fatura u riprintua në {{printer}}.",
|
||||||
|
|||||||
Reference in New Issue
Block a user