feat(booth): overstay sessions, top-up pricing, and session/feed filters
Rework paid-but-grace-expired sessions and add booth filters. Overstay (was "stuck"): - Stop silently aging out a paid transient whose walk-back grace lapsed with no signed exit. Keep it listed with an OVERSTAY badge — a new parking period began (re-parked) or the car is faulty/abandoned; it is not a system fault. - No free exit: reopenBarrier refuses server-side once a transient's payment grace has expired (allow only subscription OR paid-and-within-grace); the UI hides the Open-barrier button on overstay rows and routes to the pay/exit modal. Closes a hole where a stale payment authorized a free multi-day exit (operator-as-adversary). - Price the overstay as a NEW period from grace-expiry -> now with its own daily-cap ladder, NOT "full stay minus paid" (which a daily cap collapsed to 0 — ticket 1245791632490 owed ALL 0; now owes its real overstay). quote() gains periodStart + overstay; SessionLookup/ActiveSession gain `overstay`. handlePayAndExit charges whenever the session is payable (was: only if !alreadyPaid, skipping the overstay). Filters (new ui/FilterBar): Active Sessions — search + status (unpaid/paid/exiting/overstay) + transient-vs-subscriber. Live feed — search + event (entry/exit/pay/void/anomaly) + direction + source (booth=manual vs reader). All client-side over already-fetched data; matched/total count shown. i18n parity (sq+en). Wiki: booth-exit-flow updated (overstay model, naming history, no-free-exit security fix, new-period pricing; open question on grace-renewal noted). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -195,12 +195,22 @@ export class ExitFlow {
|
|||||||
|
|
||||||
const view = this.#sessionFor(id);
|
const view = this.#sessionFor(id);
|
||||||
if (!view) return { ok: false, reason: "no session for ticket" };
|
if (!view) return { ok: false, reason: "no session for ticket" };
|
||||||
// Authorization to re-open: a PAID transient (paid, or paid-then-exited within
|
// Authorization to re-open: a SUBSCRIPTION occurrence (prepaid — exactly the case
|
||||||
// grace) OR a SUBSCRIPTION occurrence (prepaid — exactly the case the operator must
|
// the operator must assist when the exit reader / card fails) OR a transient whose
|
||||||
// assist when the exit reader / card fails). An unpaid TRANSIENT takes the pay/exit
|
// payment is STILL WITHIN the walk-back grace window. A stale payment does NOT
|
||||||
// flow instead — enforced here, not just in the UI (the no-unpaid-bypass rule).
|
// authorize a free open: a car that paid once and then sat inside past grace owes a
|
||||||
if (view.paidAt == null && !view.subscription) {
|
// top-up for the extra time — letting it out on the old payment is the overstay-fraud
|
||||||
return { ok: false, reason: "session not paid — no barrier open without payment" };
|
// path. So we mirror the exit flow's grace check here (not just in the UI): an
|
||||||
|
// unpaid OR grace-expired transient takes the pay/exit (top-up) flow instead.
|
||||||
|
// The no-unpaid-bypass + no-free-overstay-exit rules, enforced server-side.
|
||||||
|
const paid = view.paidAt != null;
|
||||||
|
const withinGrace =
|
||||||
|
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
||||||
|
if (!view.subscription && (!paid || !withinGrace)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: paid ? "walk-back grace expired — take a top-up payment first" : "session not paid — no barrier open without payment",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = `reopen:${id}`;
|
const key = `reopen:${id}`;
|
||||||
|
|||||||
@@ -28,8 +28,18 @@ export class NoTariffError extends Error {
|
|||||||
|
|
||||||
export interface Quote {
|
export interface Quote {
|
||||||
readonly identity: string;
|
readonly identity: string;
|
||||||
|
/** Vehicle entry time (the session's original entry; for display/audit). */
|
||||||
readonly enteredAt: string;
|
readonly enteredAt: string;
|
||||||
|
/** Start of the period being billed RIGHT NOW. For a first payment this is the
|
||||||
|
* entry. For an OVERSTAY (a paid session whose walk-back grace lapsed — the car
|
||||||
|
* re-parked / a new period began) it is the moment that grace expired: the overstay
|
||||||
|
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
|
||||||
|
* "full stay minus paid" (which a daily cap collapses toward zero). */
|
||||||
|
readonly periodStart: string;
|
||||||
|
/** Amount owed now: the fee for [periodStart → now]. */
|
||||||
readonly amountMinor: number;
|
readonly amountMinor: number;
|
||||||
|
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
|
||||||
|
readonly overstay: boolean;
|
||||||
readonly currency: string;
|
readonly currency: string;
|
||||||
readonly tariffVersionId: string;
|
readonly tariffVersionId: string;
|
||||||
readonly graceExitMin: number;
|
readonly graceExitMin: number;
|
||||||
@@ -53,6 +63,13 @@ export interface ActiveSession {
|
|||||||
readonly currency: string | null;
|
readonly currency: string | null;
|
||||||
readonly withinGrace: boolean;
|
readonly withinGrace: boolean;
|
||||||
readonly graceExpiresAt: string | null;
|
readonly graceExpiresAt: string | null;
|
||||||
|
/** OVERSTAY = a paid transient whose walk-back grace lapsed with NO signed vehicle_exit.
|
||||||
|
* The car either re-parked (a new period began) or is faulty/abandoned — not a system
|
||||||
|
* fault, and not "stuck". It lingers in occupancy and owes a fresh period (priced from
|
||||||
|
* grace-expiry, see `quote`). We keep it listed and BADGE it OVERSTAY so the operator
|
||||||
|
* reconciles via a top-up, instead of silently aging it out. No free barrier open.
|
||||||
|
* See wiki/concepts/booth-exit-flow.md. */
|
||||||
|
readonly overstay: boolean;
|
||||||
/** True for a SUBSCRIPTION occurrence (prepaid — never charged). The booth shows it
|
/** True for a SUBSCRIPTION occurrence (prepaid — never charged). The booth shows it
|
||||||
* with snapshots + an always-available "open barrier" (assist a faulty exit reader /
|
* with snapshots + an always-available "open barrier" (assist a faulty exit reader /
|
||||||
* missing card), and never a pay flow. See wiki/entities/subscription.md. */
|
* missing card), and never a pay flow. See wiki/entities/subscription.md. */
|
||||||
@@ -80,6 +97,9 @@ export interface SessionLookup {
|
|||||||
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. */
|
||||||
readonly graceExpiresAt: string | null;
|
readonly graceExpiresAt: string | null;
|
||||||
|
/** OVERSTAY = paid transient, walk-back grace expired, no signed exit. A new period
|
||||||
|
* began; `amountMinor` is the fresh fee from grace-expiry — it cannot exit for free. */
|
||||||
|
readonly overstay: boolean;
|
||||||
/** True for a SUBSCRIPTION occurrence (prepaid — never charged; barrier-open only). */
|
/** True for a SUBSCRIPTION occurrence (prepaid — never charged; barrier-open only). */
|
||||||
readonly subscription: boolean;
|
readonly subscription: boolean;
|
||||||
readonly subscriptionId: string | null;
|
readonly subscriptionId: string | null;
|
||||||
@@ -97,11 +117,28 @@ export class PayStation {
|
|||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Price an open session against the tariff in force at its entry. No side effect. */
|
/** Price an open session. Normally the period is entry→now. But for an OVERSTAY — a
|
||||||
|
* paid session whose walk-back grace has lapsed (the car re-parked, or a new period
|
||||||
|
* began) — the customer is billed for a FRESH period from grace-expiry→now, with its
|
||||||
|
* own daily-cap ladder. This is NOT "full stay minus paid": with a daily cap the
|
||||||
|
* whole-stay gross plateaus while prior payments keep pace, so the delta collapses to
|
||||||
|
* 0 and a multi-day overstay would exit free (ticket 1245791632490). A new period
|
||||||
|
* reflects the reality and re-accrues the fee. No side effect. */
|
||||||
quote(identity: string): Quote {
|
quote(identity: string): Quote {
|
||||||
const entry = this.#openEntry(identity);
|
const entry = this.#openEntry(identity);
|
||||||
if (!entry) throw new NoOpenSessionError(identity);
|
if (!entry) throw new NoOpenSessionError(identity);
|
||||||
|
|
||||||
|
// If the latest payment's walk-back grace has expired, this is an overstay: anchor
|
||||||
|
// the new billing period at grace-expiry (paidAt + graceExitMin). Otherwise price
|
||||||
|
// from entry (first payment, or a still-within-grace re-quote of the same stay).
|
||||||
|
const last = this.#lastPayment(identity);
|
||||||
|
const graceExpiryMs =
|
||||||
|
last && last.graceExitMin != null ? Date.parse(last.paidAt) + last.graceExitMin * 60_000 : null;
|
||||||
|
const overstay = graceExpiryMs != null && Date.now() > graceExpiryMs;
|
||||||
|
const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : entry.occurredAt;
|
||||||
|
|
||||||
|
// The tariff in force is keyed to ENTRY (the version frozen for this session), even
|
||||||
|
// for an overstay period — the customer keeps the rate card they entered under.
|
||||||
const tv = this.#tariffVersionFor(entry.occurredAt);
|
const tv = this.#tariffVersionFor(entry.occurredAt);
|
||||||
if (!tv) throw new NoTariffError();
|
if (!tv) throw new NoTariffError();
|
||||||
const structure = tv.structure as unknown as TariffStructure;
|
const structure = tv.structure as unknown as TariffStructure;
|
||||||
@@ -110,23 +147,44 @@ export class PayStation {
|
|||||||
// both read it from there, so a V2 category tariff yields the same amount at the
|
// both read it from there, so a V2 category tariff yields the same amount at the
|
||||||
// booth and at exit. Absent (legacy/V1) ⇒ undefined ⇒ category-agnostic pricing.
|
// booth and at exit. Absent (legacy/V1) ⇒ undefined ⇒ category-agnostic pricing.
|
||||||
const category = (entry.payload as { category?: string } | null)?.category;
|
const category = (entry.payload as { category?: string } | null)?.category;
|
||||||
const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure, category);
|
const amountMinor = computeFee(periodStart, new Date().toISOString(), structure, category);
|
||||||
return {
|
return {
|
||||||
identity,
|
identity,
|
||||||
enteredAt: entry.occurredAt,
|
enteredAt: entry.occurredAt,
|
||||||
|
periodStart,
|
||||||
amountMinor,
|
amountMinor,
|
||||||
|
overstay,
|
||||||
currency: tv.currency,
|
currency: tv.currency,
|
||||||
tariffVersionId: tv.id,
|
tariffVersionId: tv.id,
|
||||||
graceExitMin: structure.gracePeriodExitMin,
|
graceExitMin: structure.gracePeriodExitMin,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The latest signed `payment` for this session (time + the grace window it granted),
|
||||||
|
* or null if never paid. Folds the append-only ledger. */
|
||||||
|
#lastPayment(identity: string): { paidAt: string; graceExitMin: number | null } | null {
|
||||||
|
const rows = this.#db
|
||||||
|
.select({ type: ledgerEvents.type, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload })
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, identity))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
let last: { paidAt: string; graceExitMin: number | null } | null = null;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type !== "payment") continue;
|
||||||
|
const g = (r.payload as { graceExitMin?: number } | null)?.graceExitMin;
|
||||||
|
last = { paidAt: r.occurredAt, graceExitMin: typeof g === "number" ? g : null };
|
||||||
|
}
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Take payment for a session and append the signed `payment` event. Re-quotes at
|
* Take payment for a session and append the signed `payment` event. Re-quotes at
|
||||||
* the moment of payment (the customer pays for time parked SO FAR). For an
|
* the moment of payment (the customer pays for time parked SO FAR). For an OVERSTAY
|
||||||
* overstay top-up the same call re-prices entry→now and the exit flow's
|
* (grace lapsed) the quote prices a fresh period from grace-expiry→now (see `quote`),
|
||||||
* grace-window restarts from this payment. `overrideMinor` lets the operator set
|
* and this payment writes a new `graceExitMin` so the walk-back window restarts.
|
||||||
* an arbitrary amount (lost ticket / dispute) — recorded as the charged amount.
|
* `overrideMinor` lets the operator set an arbitrary amount (lost ticket / dispute) —
|
||||||
|
* recorded as the charged amount.
|
||||||
*/
|
*/
|
||||||
async pay(
|
async pay(
|
||||||
identity: string,
|
identity: string,
|
||||||
@@ -183,7 +241,7 @@ export class PayStation {
|
|||||||
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, withinGrace: false, graceExpiresAt: null,
|
||||||
subscription: false, subscriptionId: null, subscriptionHolder: null,
|
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
||||||
@@ -220,10 +278,12 @@ export class PayStation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const overstay = open && !isSubscription && paidAt != null && graceExpiresAt != null && !withinGrace;
|
||||||
|
|
||||||
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,
|
paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay,
|
||||||
subscription: isSubscription, subscriptionId,
|
subscription: isSubscription, subscriptionId,
|
||||||
subscriptionHolder: this.#holderOf(subscriptionId),
|
subscriptionHolder: this.#holderOf(subscriptionId),
|
||||||
};
|
};
|
||||||
@@ -287,26 +347,29 @@ export class PayStation {
|
|||||||
const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt);
|
const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt);
|
||||||
const paid = a.paidAt != null;
|
const paid = a.paidAt != null;
|
||||||
|
|
||||||
|
const isSubscription = a.subscriptionId !== undefined;
|
||||||
|
|
||||||
// ACTIVE membership:
|
// ACTIVE membership:
|
||||||
// - exited + within grace → still shown (barrier unconfirmed, may be present);
|
// - exited + within grace → still shown (barrier unconfirmed, may be present);
|
||||||
// - exited + past grace → presumed gone, omitted;
|
// - exited + past grace → presumed gone, omitted;
|
||||||
// - open + UNPAID → always shown (a car owing money never ages out —
|
// - open + UNPAID → always shown (a car owing money never ages out —
|
||||||
// it's genuinely still inside until it pays, however long that takes);
|
// it's genuinely still inside until it pays, however long that takes);
|
||||||
// - open + PAID + past grace → AGE-OUT (omit). A paid car whose walk-back grace
|
// - open + PAID + past grace → OVERSTAY. A paid transient whose walk-back grace
|
||||||
// lapsed has left; if no vehicle_exit was ever signed (e.g. it left via a
|
// lapsed with no signed vehicle_exit: the car re-parked (a new period) or is
|
||||||
// manual barrier re-open before that path closed the session, or a historical
|
// faulty/abandoned — not a system fault, not "stuck". It lingers in occupancy
|
||||||
// session like T-397815c0) it would otherwise linger forever. The signed log
|
// and owes a fresh period (priced from grace-expiry, see `quote`). We used to
|
||||||
// is unchanged — this is purely a display filter. See booth-exit-flow.md.
|
// age these out (a silent display filter); now we KEEP them and flag `overstay`
|
||||||
|
// so the operator reconciles via a top-up. The signed log is untouched, and the
|
||||||
|
// barrier never opens for free on these. See booth-exit-flow.md.
|
||||||
if (!open && !withinGrace) continue;
|
if (!open && !withinGrace) continue;
|
||||||
if (open && paid && graceExpiresAt != null && !withinGrace) continue;
|
const overstay =
|
||||||
|
open && paid && !isSubscription && graceExpiresAt != null && !withinGrace;
|
||||||
|
|
||||||
const isSubscription = a.subscriptionId !== undefined;
|
// Amount owed now: an open + unpaid TRANSIENT (first stay) OR an OVERSTAY (the new
|
||||||
|
// period's top-up). A subscription is prepaid — never quote/charge it.
|
||||||
// Amount owed now: only meaningful for an open + unpaid TRANSIENT session. A
|
|
||||||
// subscription is prepaid — never quote/charge it.
|
|
||||||
let amountMinor: number | null = null;
|
let amountMinor: number | null = null;
|
||||||
let currency: string | null = null;
|
let currency: string | null = null;
|
||||||
if (open && a.paidAt == null && !isSubscription) {
|
if (open && !isSubscription && (a.paidAt == null || overstay)) {
|
||||||
try {
|
try {
|
||||||
const q = this.quote(identity);
|
const q = this.quote(identity);
|
||||||
amountMinor = q.amountMinor;
|
amountMinor = q.amountMinor;
|
||||||
@@ -327,6 +390,7 @@ export class PayStation {
|
|||||||
currency,
|
currency,
|
||||||
withinGrace,
|
withinGrace,
|
||||||
graceExpiresAt,
|
graceExpiresAt,
|
||||||
|
overstay,
|
||||||
subscription: isSubscription,
|
subscription: isSubscription,
|
||||||
subscriptionId: a.subscriptionId ?? null,
|
subscriptionId: a.subscriptionId ?? null,
|
||||||
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),
|
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),
|
||||||
|
|||||||
+119
-49
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
|
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
|
||||||
@@ -6,6 +6,7 @@ import { qk } from "./lib/query.js";
|
|||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { 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";
|
||||||
|
|
||||||
// Active Sessions panel. A session is "active" while still inside OR exited-but-
|
// 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
|
// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed
|
||||||
@@ -14,10 +15,28 @@ import { Panel } from "./ui/Panel.js";
|
|||||||
// - click a row → the pay/exit modal (pay an unpaid car, or review),
|
// - click a row → the pay/exit modal (pay an unpaid car, or review),
|
||||||
// - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse.
|
// - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse.
|
||||||
// No payment → no Open barrier button (the no-unpaid-bypass rule).
|
// No payment → no Open barrier button (the no-unpaid-bypass rule).
|
||||||
|
//
|
||||||
|
// 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.
|
// See wiki/concepts/booth-exit-flow.md.
|
||||||
|
|
||||||
function statusBadge(s: ActiveSession): { key: string; cls: string } {
|
type StatusFilter = "unpaid" | "paid" | "exiting" | "overstay";
|
||||||
|
type KindFilter = "transient" | "subscription";
|
||||||
|
|
||||||
|
function statusOf(s: ActiveSession): StatusFilter | "subscription" {
|
||||||
|
if (s.subscription) return "subscription";
|
||||||
|
if (s.overstay) return "overstay";
|
||||||
|
if (!s.open && s.withinGrace) return "exiting";
|
||||||
|
if (s.paidAt) return "paid";
|
||||||
|
return "unpaid";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(s: ActiveSession): { key: string; titleKey?: string; cls: string } {
|
||||||
if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" };
|
if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" };
|
||||||
|
if (s.overstay)
|
||||||
|
return { key: "booth.badgeOverstay", titleKey: "booth.badgeOverstayTitle", cls: "text-term-red" };
|
||||||
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
|
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
|
||||||
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
|
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
|
||||||
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
|
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
|
||||||
@@ -47,7 +66,36 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
});
|
});
|
||||||
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
|
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
|
||||||
|
|
||||||
const sessions = data?.sessions ?? [];
|
// Filters: free-text search, status, and transient-vs-subscriber.
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [status, setStatus] = useState<StatusFilter | "">("");
|
||||||
|
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 (status && statusOf(s) !== status) return false;
|
||||||
|
if (q) {
|
||||||
|
const hay = `${s.identity} ${s.subscriptionHolder ?? ""}`.toLowerCase();
|
||||||
|
if (!hay.includes(q)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [sessions, search, status, kind]);
|
||||||
|
|
||||||
|
const statusOpts: SegOption<StatusFilter>[] = [
|
||||||
|
{ value: "unpaid", label: t("booth.fStatusUnpaid") },
|
||||||
|
{ value: "paid", label: t("booth.fStatusPaid") },
|
||||||
|
{ value: "exiting", label: t("booth.fStatusExiting") },
|
||||||
|
{ value: "overstay", label: t("booth.fStatusOverstay") },
|
||||||
|
];
|
||||||
|
const kindOpts: SegOption<KindFilter>[] = [
|
||||||
|
{ value: "transient", label: t("booth.fKindTransient") },
|
||||||
|
{ value: "subscription", label: t("booth.fKindSubscription") },
|
||||||
|
];
|
||||||
|
|
||||||
async function handleReopen(s: ActiveSession) {
|
async function handleReopen(s: ActiveSession) {
|
||||||
setReopenMsg(null);
|
setReopenMsg(null);
|
||||||
@@ -68,62 +116,84 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
title={t("booth.activeSessions")}
|
title={t("booth.activeSessions")}
|
||||||
right={
|
right={
|
||||||
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
||||||
{sessions.length} {t("booth.insideCount")}
|
{filtered.length}
|
||||||
|
{filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
className="min-h-0 flex-1"
|
className="min-h-0 flex-1"
|
||||||
>
|
>
|
||||||
<div className="h-full overflow-y-auto pr-1">
|
<div className="flex h-full flex-col">
|
||||||
{sessions.length === 0 ? (
|
<FilterBar search={search} onSearch={setSearch} searchPlaceholder={t("booth.filterSearchSessions")}>
|
||||||
<div className="text-term-muted">{isLoading ? t("common.loading") : t("booth.noActiveSessions")}</div>
|
<SegGroup value={status} options={statusOpts} onChange={setStatus} allLabel={t("booth.filterAll")} />
|
||||||
) : (
|
<SegGroup value={kind} options={kindOpts} onChange={setKind} allLabel={t("booth.filterAll")} />
|
||||||
sessions.map((s) => {
|
</FilterBar>
|
||||||
const badge = statusBadge(s);
|
|
||||||
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={s.identity}
|
|
||||||
className="flex items-center gap-3 border-b border-term-border/50 py-1.5 text-[12px] tabular-nums"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onPick(s.identity)}
|
|
||||||
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
|
|
||||||
title={t("booth.openPayExit")}
|
|
||||||
>
|
|
||||||
<span className="text-term-text">
|
|
||||||
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
|
|
||||||
</span>
|
|
||||||
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span>
|
|
||||||
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
|
|
||||||
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{t(badge.key)}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Open barrier — PAID transient OR a SUBSCRIPTION (prepaid). An
|
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||||
unpaid transient has no button (no-unpaid-bypass). */}
|
{filtered.length === 0 ? (
|
||||||
{s.paidAt || s.subscription ? (
|
<div className="text-term-muted">
|
||||||
|
{isLoading
|
||||||
|
? t("common.loading")
|
||||||
|
: sessions.length === 0
|
||||||
|
? t("booth.noActiveSessions")
|
||||||
|
: t("booth.noMatch")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((s) => {
|
||||||
|
const badge = statusBadge(s);
|
||||||
|
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.identity}
|
||||||
|
className="flex items-center gap-3 border-b border-term-border/50 py-1.5 text-[12px] tabular-nums"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={reopen.isPending || !shiftReady}
|
onClick={() => onPick(s.identity)}
|
||||||
onClick={() => handleReopen(s)}
|
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
|
||||||
className="btn btn-pay btn-sm shrink-0"
|
title={t("booth.openPayExit")}
|
||||||
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
|
|
||||||
>
|
>
|
||||||
{t("booth.openBarrier")}
|
<span className="text-term-text">
|
||||||
|
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
|
||||||
|
</span>
|
||||||
|
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span>
|
||||||
|
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
|
||||||
|
<span
|
||||||
|
className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}
|
||||||
|
title={badge.titleKey ? t(badge.titleKey) : undefined}
|
||||||
|
>
|
||||||
|
{t(badge.key)}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
|
||||||
<span className="w-[88px] shrink-0" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{msg && (
|
{/* Open barrier — PAID-and-still-in-grace transient OR a SUBSCRIPTION
|
||||||
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
(prepaid). NOT an OVERSTAY session: its grace has expired, so the car
|
||||||
{msg.text}
|
owes a top-up — the row routes to the pay/exit modal instead (no
|
||||||
</span>
|
free overstay exit). An unpaid transient also has no button
|
||||||
)}
|
(no-unpaid-bypass). Mirrors reopenBarrier's server-side guard. */}
|
||||||
</div>
|
{(s.paidAt && !s.overstay) || s.subscription ? (
|
||||||
);
|
<button
|
||||||
})
|
type="button"
|
||||||
)}
|
disabled={reopen.isPending || !shiftReady}
|
||||||
|
onClick={() => handleReopen(s)}
|
||||||
|
className="btn btn-pay btn-sm shrink-0"
|
||||||
|
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
|
||||||
|
>
|
||||||
|
{t("booth.openBarrier")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="w-[88px] shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{msg && (
|
||||||
|
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
||||||
|
{msg.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,9 +52,16 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
|
|
||||||
const alreadyPaid = s?.paidAt != null;
|
const alreadyPaid = s?.paidAt != null;
|
||||||
const isSubscription = s?.subscription === true;
|
const isSubscription = s?.subscription === true;
|
||||||
|
// OVERSTAY = paid but walk-back grace expired with no exit → a NEW period began; owes
|
||||||
|
// a fresh TOP-UP. Treat it as payable even though it's "already paid": the car must
|
||||||
|
// settle the new period's fee (s.amountMinor, priced from grace-expiry) before any
|
||||||
|
// 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;
|
||||||
// A subscription is prepaid: never charged. The only booth action is an audited
|
// A subscription is prepaid: never charged. The only booth action is an audited
|
||||||
// barrier open to ASSIST (faulty exit reader / lost card). Transient pay path is off.
|
// barrier open to ASSIST (faulty exit reader / lost card). Transient pay path is off.
|
||||||
const canPay = shiftReady && s?.found && s.open && !alreadyPaid && !isSubscription;
|
// Allow pay for an unpaid session OR an overstay (new-period top-up) one.
|
||||||
|
const canPay = !!(shiftReady && s?.found && s.open && (!alreadyPaid || isOverstay) && !isSubscription);
|
||||||
|
|
||||||
async function handleOpenBarrier() {
|
async function handleOpenBarrier() {
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
@@ -103,8 +110,11 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
if (!s) return;
|
if (!s) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
// 1. Take payment (unless already paid — e.g. paid earlier at a kiosk).
|
// 1. Take payment. For a first stay this is the only charge; for an OVERSTAY the
|
||||||
if (!alreadyPaid) {
|
// session is "already paid" but a new period accrued — we still charge (canPay
|
||||||
|
// is true). A settled within-grace session is not payable (canPay false) and is
|
||||||
|
// skipped. The server re-quotes authoritatively (overstay → from grace-expiry).
|
||||||
|
if (canPay) {
|
||||||
setPhase("paying");
|
setPhase("paying");
|
||||||
await paySession(identity, tender);
|
await paySession(identity, tender);
|
||||||
}
|
}
|
||||||
@@ -221,15 +231,32 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label={t("pay.statusLabel")}
|
label={t("pay.statusLabel")}
|
||||||
value={isSubscription ? t("pay.subscription") : alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
|
value={
|
||||||
valueClass={isSubscription ? "text-term-cyan" : alreadyPaid ? "text-term-green" : "text-term-amber"}
|
isSubscription
|
||||||
|
? t("pay.subscription")
|
||||||
|
: isOverstay
|
||||||
|
? t("pay.overstay")
|
||||||
|
: alreadyPaid
|
||||||
|
? t("pay.paid")
|
||||||
|
: t("pay.unpaid")
|
||||||
|
}
|
||||||
|
valueClass={
|
||||||
|
isSubscription
|
||||||
|
? "text-term-cyan"
|
||||||
|
: isOverstay
|
||||||
|
? "text-term-red"
|
||||||
|
: alreadyPaid
|
||||||
|
? "text-term-green"
|
||||||
|
: "text-term-amber"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Total — a subscription is prepaid (no amount); show a badge. */}
|
{/* Total — a subscription is prepaid (no amount); show a badge. For an
|
||||||
|
overstay the 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-[11px] uppercase tracking-wider text-term-muted">
|
<span className="text-[11px] uppercase tracking-wider text-term-muted">
|
||||||
{isSubscription ? t("pay.plan") : t("pay.total")}
|
{isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-3xl font-bold text-term-cyan">
|
<span className="text-3xl font-bold text-term-cyan">
|
||||||
{isSubscription
|
{isSubscription
|
||||||
@@ -249,6 +276,13 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* For an overstay, explain why a top-up is required (no free exit). */}
|
||||||
|
{isOverstay && (
|
||||||
|
<div className="rounded-term border border-term-red/40 bg-term-red/5 px-3 py-2 text-[12px] text-term-text">
|
||||||
|
{t("pay.overstayHint")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Snapshots */}
|
{/* Snapshots */}
|
||||||
<SnapshotStrip identity={identity} />
|
<SnapshotStrip identity={identity} />
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { BoothPayModal } from "./BoothPayModal.js";
|
|||||||
import { ActiveSessions } from "./ActiveSessions.js";
|
import { ActiveSessions } from "./ActiveSessions.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||||||
|
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
||||||
import { renderReason } from "./lib/reason.js";
|
import { renderReason } from "./lib/reason.js";
|
||||||
|
|
||||||
// The live operator booth view — the real-time heart of the console. Occupancy
|
// The live operator booth view — the real-time heart of the console. Occupancy
|
||||||
@@ -33,6 +34,27 @@ const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
|
|||||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Live-feed filter category for an event type. Several ledger types collapse into a
|
||||||
|
// few operator-meaningful buckets; the rest (barrier/shift/cash) fall outside the
|
||||||
|
// filter and only show under "all".
|
||||||
|
type FeedCat = "entry" | "exit" | "pay" | "void" | "anomaly";
|
||||||
|
function feedCat(type: string): FeedCat | null {
|
||||||
|
switch (type) {
|
||||||
|
case "vehicle_entry":
|
||||||
|
return "entry";
|
||||||
|
case "vehicle_exit":
|
||||||
|
return "exit";
|
||||||
|
case "payment":
|
||||||
|
return "pay";
|
||||||
|
case "void":
|
||||||
|
return "void";
|
||||||
|
case "anomaly":
|
||||||
|
return "anomaly";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function hhmmss(iso: string): string {
|
function hhmmss(iso: string): string {
|
||||||
// Local time-of-day, terminal style. Defensive against a bad timestamp.
|
// Local time-of-day, terminal style. Defensive against a bad timestamp.
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -372,6 +394,12 @@ export function BoothScreen() {
|
|||||||
// The ledger event open in the read-only detail modal (null = closed).
|
// The ledger event open in the read-only detail modal (null = closed).
|
||||||
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
||||||
|
|
||||||
|
// Live-feed filters: free-text search, event category, and direction/source.
|
||||||
|
const [feedSearch, setFeedSearch] = useState("");
|
||||||
|
const [feedType, setFeedType] = useState<FeedCat | "">("");
|
||||||
|
const [feedDir, setFeedDir] = useState<"entry" | "exit" | "">("");
|
||||||
|
const [feedSrc, setFeedSrc] = useState<"booth" | "reader" | "">("");
|
||||||
|
|
||||||
// Live overlays from the WS store.
|
// Live overlays from the WS store.
|
||||||
const liveOcc = useLiveStore((s) => s.occupancy);
|
const liveOcc = useLiveStore((s) => s.occupancy);
|
||||||
const liveFeed = useLiveStore((s) => s.feed);
|
const liveFeed = useLiveStore((s) => s.feed);
|
||||||
@@ -385,11 +413,45 @@ export function BoothScreen() {
|
|||||||
const seen = new Set(liveFeed.map((e) => e.id));
|
const seen = new Set(liveFeed.map((e) => e.id));
|
||||||
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
|
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
|
||||||
const merged = [...liveFeed, ...history].slice(0, 200);
|
const merged = [...liveFeed, ...history].slice(0, 200);
|
||||||
const events =
|
const scoped =
|
||||||
shiftOpen && shiftStart
|
shiftOpen && shiftStart
|
||||||
? merged.filter((e) => e.occurredAt >= shiftStart)
|
? merged.filter((e) => e.occurredAt >= shiftStart)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
|
||||||
|
// vs reader (device-initiated: wiegand/lpr/qr/ticket). Search spans identity,
|
||||||
|
// subscriber label, and any advisory plate on the payload.
|
||||||
|
const fq = feedSearch.trim().toLowerCase();
|
||||||
|
const events = scoped.filter((e) => {
|
||||||
|
if (feedType && feedCat(e.type) !== feedType) return false;
|
||||||
|
if (feedDir && e.direction !== feedDir) return false;
|
||||||
|
if (feedSrc) {
|
||||||
|
const isBooth = e.source === "manual";
|
||||||
|
if (feedSrc === "booth" ? !isBooth : isBooth) return false;
|
||||||
|
}
|
||||||
|
if (fq) {
|
||||||
|
const hay = `${e.identity ?? ""} ${e.subscriberLabel ?? ""} ${e.payload?.plate ?? ""}`.toLowerCase();
|
||||||
|
if (!hay.includes(fq)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const feedTypeOpts: SegOption<FeedCat>[] = [
|
||||||
|
{ value: "entry", label: t("booth.fEvtEntry") },
|
||||||
|
{ value: "exit", label: t("booth.fEvtExit") },
|
||||||
|
{ value: "pay", label: t("booth.fEvtPay") },
|
||||||
|
{ value: "void", label: t("booth.fEvtVoid") },
|
||||||
|
{ value: "anomaly", label: t("booth.fEvtAnomaly") },
|
||||||
|
];
|
||||||
|
const feedDirOpts: SegOption<"entry" | "exit">[] = [
|
||||||
|
{ value: "entry", label: t("booth.fDirEntry") },
|
||||||
|
{ value: "exit", label: t("booth.fDirExit") },
|
||||||
|
];
|
||||||
|
const feedSrcOpts: SegOption<"booth" | "reader">[] = [
|
||||||
|
{ value: "booth", label: t("booth.fSrcBooth") },
|
||||||
|
{ value: "reader", label: t("booth.fSrcReader") },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
|
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
|
||||||
{/* Ticket input spans both columns at the top — the operator's primary action. */}
|
{/* Ticket input spans both columns at the top — the operator's primary action. */}
|
||||||
@@ -417,19 +479,40 @@ export function BoothScreen() {
|
|||||||
title={t("booth.liveFeed")}
|
title={t("booth.liveFeed")}
|
||||||
right={
|
right={
|
||||||
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
||||||
{events.length} {t("booth.events")}
|
{events.length}
|
||||||
|
{events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
className="min-h-0"
|
className="min-h-0"
|
||||||
>
|
>
|
||||||
<div className="h-full overflow-y-auto pr-1">
|
<div className="flex h-full flex-col">
|
||||||
{!shiftOpen ? (
|
{shiftOpen && (
|
||||||
<div className="text-term-amber">{t("shift.gateTitle")}</div>
|
<FilterBar search={feedSearch} onSearch={setFeedSearch} searchPlaceholder={t("booth.filterSearchFeed")}>
|
||||||
) : events.length === 0 ? (
|
<SegGroup
|
||||||
<div className="text-term-muted">{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}</div>
|
value={feedType}
|
||||||
) : (
|
options={feedTypeOpts}
|
||||||
events.map((e) => <EventRow key={e.id} e={e} onOpen={setDetailEvent} />)
|
onChange={setFeedType}
|
||||||
|
allLabel={t("booth.filterAll")}
|
||||||
|
/>
|
||||||
|
<SegGroup value={feedDir} options={feedDirOpts} onChange={setFeedDir} allLabel={t("booth.filterAll")} />
|
||||||
|
<SegGroup value={feedSrc} options={feedSrcOpts} onChange={setFeedSrc} allLabel={t("booth.filterAll")} />
|
||||||
|
</FilterBar>
|
||||||
)}
|
)}
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||||
|
{!shiftOpen ? (
|
||||||
|
<div className="text-term-amber">{t("shift.gateTitle")}</div>
|
||||||
|
) : events.length === 0 ? (
|
||||||
|
<div className="text-term-muted">
|
||||||
|
{eventsQuery.isLoading
|
||||||
|
? t("common.loading")
|
||||||
|
: scoped.length === 0
|
||||||
|
? t("booth.noEventsYet")
|
||||||
|
: t("booth.noMatch")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
events.map((e) => <EventRow key={e.id} e={e} onOpen={setDetailEvent} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
|||||||
@@ -694,6 +694,9 @@ export interface SessionLookup {
|
|||||||
currency: string | null;
|
currency: 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;
|
||||||
|
* owes a fresh top-up (amountMinor); cannot exit for free. */
|
||||||
|
overstay: boolean;
|
||||||
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
|
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
|
||||||
subscription: boolean;
|
subscription: boolean;
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
@@ -717,6 +720,10 @@ export interface ActiveSession {
|
|||||||
currency: string | null;
|
currency: string | null;
|
||||||
withinGrace: boolean;
|
withinGrace: boolean;
|
||||||
graceExpiresAt: string | null;
|
graceExpiresAt: string | null;
|
||||||
|
/** OVERSTAY: paid transient whose walk-back grace lapsed with no signed exit — a new
|
||||||
|
* period began (re-parked) or the car is faulty/abandoned. Owes a fresh top-up;
|
||||||
|
* flagged so the operator reconciles, never a free exit. */
|
||||||
|
overstay: boolean;
|
||||||
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
|
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
|
||||||
subscription: boolean;
|
subscription: boolean;
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ export const en: Catalog = {
|
|||||||
devices: {
|
devices: {
|
||||||
footerTitle: "Devices",
|
footerTitle: "Devices",
|
||||||
none: "No devices configured.",
|
none: "No devices configured.",
|
||||||
catAccess: "Barrier",
|
catAccess: "Relay",
|
||||||
catReader: "Reader",
|
catReader: "Reader",
|
||||||
catCamera: "Camera",
|
catCamera: "Camera",
|
||||||
catPrinter: "Printer",
|
catPrinter: "Printer",
|
||||||
catVision: "Vision",
|
catVision: "ANPR",
|
||||||
// Role/direction suffixes for the chip label (e.g. "Reader entry").
|
// Role/direction suffixes for the chip label (e.g. "Reader entry").
|
||||||
role: {
|
role: {
|
||||||
entry: "entry",
|
entry: "entry",
|
||||||
@@ -101,6 +101,29 @@ export const en: Catalog = {
|
|||||||
activeSessions: "Active sessions",
|
activeSessions: "Active sessions",
|
||||||
insideCount: "inside",
|
insideCount: "inside",
|
||||||
noActiveSessions: "No active sessions.",
|
noActiveSessions: "No active sessions.",
|
||||||
|
noMatch: "No sessions match the filter.",
|
||||||
|
badgeOverstay: "overstay",
|
||||||
|
badgeOverstayTitle:
|
||||||
|
"Paid session. The customer failed to exit during the grace period. A new period began.",
|
||||||
|
// filters
|
||||||
|
filterSearchSessions: "Search ticket / subscriber / plate…",
|
||||||
|
filterSearchFeed: "Search event / identity / plate…",
|
||||||
|
filterAll: "All",
|
||||||
|
fStatusUnpaid: "Unpaid",
|
||||||
|
fStatusPaid: "Paid",
|
||||||
|
fStatusExiting: "Exiting",
|
||||||
|
fStatusOverstay: "Overstay",
|
||||||
|
fKindTransient: "Transient",
|
||||||
|
fKindSubscription: "Subscribers",
|
||||||
|
fDirEntry: "Entry",
|
||||||
|
fDirExit: "Exit",
|
||||||
|
fSrcBooth: "Booth",
|
||||||
|
fSrcReader: "Reader",
|
||||||
|
fEvtEntry: "Entry",
|
||||||
|
fEvtExit: "Exit",
|
||||||
|
fEvtPay: "Pay",
|
||||||
|
fEvtVoid: "Void",
|
||||||
|
fEvtAnomaly: "Anomaly",
|
||||||
openPayExit: "Open pay / exit",
|
openPayExit: "Open pay / exit",
|
||||||
openBarrier: "Open barrier",
|
openBarrier: "Open barrier",
|
||||||
openBarrierTitle: "Human-intervention barrier open (audited)",
|
openBarrierTitle: "Human-intervention barrier open (audited)",
|
||||||
@@ -523,6 +546,9 @@ export const en: Catalog = {
|
|||||||
statusLabel: "Status",
|
statusLabel: "Status",
|
||||||
paid: "PAID",
|
paid: "PAID",
|
||||||
unpaid: "UNPAID",
|
unpaid: "UNPAID",
|
||||||
|
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.",
|
||||||
|
topUp: "New period due",
|
||||||
total: "Total",
|
total: "Total",
|
||||||
noTariff: "no tariff",
|
noTariff: "no tariff",
|
||||||
tender: "Tender",
|
tender: "Tender",
|
||||||
|
|||||||
@@ -61,11 +61,11 @@ export const sq = {
|
|||||||
devices: {
|
devices: {
|
||||||
footerTitle: "Pajisjet",
|
footerTitle: "Pajisjet",
|
||||||
none: "Asnjë pajisje e konfiguruar.",
|
none: "Asnjë pajisje e konfiguruar.",
|
||||||
catAccess: "Barriera",
|
catAccess: "Rele",
|
||||||
catReader: "Lexuesi",
|
catReader: "Lexuesi",
|
||||||
catCamera: "Kamera",
|
catCamera: "Kamera",
|
||||||
catPrinter: "Printer",
|
catPrinter: "Printer",
|
||||||
catVision: "Vizioni",
|
catVision: "ANPR",
|
||||||
// Role/direction suffixes for the chip label (e.g. "Lexuesi hyrje").
|
// Role/direction suffixes for the chip label (e.g. "Lexuesi hyrje").
|
||||||
role: {
|
role: {
|
||||||
entry: "hyrje",
|
entry: "hyrje",
|
||||||
@@ -103,6 +103,29 @@ export const sq = {
|
|||||||
activeSessions: "Sesionet aktive",
|
activeSessions: "Sesionet aktive",
|
||||||
insideCount: "brenda",
|
insideCount: "brenda",
|
||||||
noActiveSessions: "Asnjë sesion aktiv.",
|
noActiveSessions: "Asnjë sesion aktiv.",
|
||||||
|
noMatch: "Asnjë rezultat për filtrin.",
|
||||||
|
badgeOverstay: "tej afatit",
|
||||||
|
badgeOverstayTitle:
|
||||||
|
"Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.",
|
||||||
|
// filtra
|
||||||
|
filterSearchSessions: "Kërko biletë / abonent / targë…",
|
||||||
|
filterSearchFeed: "Kërko event / identitet / targë…",
|
||||||
|
filterAll: "Të gjitha",
|
||||||
|
fStatusUnpaid: "Papaguar",
|
||||||
|
fStatusPaid: "Paguar",
|
||||||
|
fStatusExiting: "Duke dalë",
|
||||||
|
fStatusOverstay: "Tej afatit",
|
||||||
|
fKindTransient: "Kalimtarë",
|
||||||
|
fKindSubscription: "Abonentë",
|
||||||
|
fDirEntry: "Hyrje",
|
||||||
|
fDirExit: "Dalje",
|
||||||
|
fSrcBooth: "Kabinë",
|
||||||
|
fSrcReader: "Lexues",
|
||||||
|
fEvtEntry: "Hyrje",
|
||||||
|
fEvtExit: "Dalje",
|
||||||
|
fEvtPay: "Pagesë",
|
||||||
|
fEvtVoid: "Anulim",
|
||||||
|
fEvtAnomaly: "Anomali",
|
||||||
openPayExit: "Hap pagesën / daljen",
|
openPayExit: "Hap pagesën / daljen",
|
||||||
openBarrier: "Hap barrierën",
|
openBarrier: "Hap barrierën",
|
||||||
openBarrierTitle: "Hap barrierën manualisht",
|
openBarrierTitle: "Hap barrierën manualisht",
|
||||||
@@ -537,6 +560,9 @@ export const sq = {
|
|||||||
statusLabel: "Statusi",
|
statusLabel: "Statusi",
|
||||||
paid: "PAGUAR",
|
paid: "PAGUAR",
|
||||||
unpaid: "PAPAGUAR",
|
unpaid: "PAPAGUAR",
|
||||||
|
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.",
|
||||||
|
topUp: "Periudha e re për pagesë",
|
||||||
total: "Totali",
|
total: "Totali",
|
||||||
noTariff: "pa tarifë",
|
noTariff: "pa tarifë",
|
||||||
tender: "Mënyra",
|
tender: "Mënyra",
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// A compact filter toolbar shared by the session list and the live feed: a search
|
||||||
|
// box plus one or more segmented toggle groups. Purely presentational — each tab
|
||||||
|
// owns its own filter state and predicates; this just lays the controls out in the
|
||||||
|
// terminal theme. Kept tiny on purpose (the booth screen is dense).
|
||||||
|
|
||||||
|
export interface SegOption<V extends string> {
|
||||||
|
readonly value: V;
|
||||||
|
readonly label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A segmented single-select (e.g. status / direction). `value` "" = "all". */
|
||||||
|
export function SegGroup<V extends string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
allLabel,
|
||||||
|
}: {
|
||||||
|
value: V | "";
|
||||||
|
options: readonly SegOption<V>[];
|
||||||
|
onChange: (v: V | "") => void;
|
||||||
|
allLabel: string;
|
||||||
|
}) {
|
||||||
|
const seg = (v: V | "", label: string) => (
|
||||||
|
<button
|
||||||
|
key={v || "all"}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(v)}
|
||||||
|
className={`px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${
|
||||||
|
value === v ? "bg-term-border text-term-text" : "text-term-muted hover:text-term-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="flex shrink-0 overflow-hidden rounded border border-term-border/60">
|
||||||
|
{seg("", allLabel)}
|
||||||
|
{options.map((o) => seg(o.value, o.label))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterBar({
|
||||||
|
search,
|
||||||
|
onSearch,
|
||||||
|
searchPlaceholder,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
search: string;
|
||||||
|
onSearch: (v: string) => void;
|
||||||
|
searchPlaceholder: string;
|
||||||
|
/** Segmented groups (one or more <SegGroup/>). */
|
||||||
|
children?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-2 border-b border-term-border/50 pb-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearch(e.target.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className="min-w-[8rem] flex-1 rounded border border-term-border/60 bg-transparent px-2 py-0.5 text-[12px] text-term-text placeholder:text-term-muted focus:border-term-amber focus:outline-none"
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -84,12 +84,54 @@ phantom obstacle: an animal, a person, a cardboard box or bag in the wind). Thes
|
|||||||
present* until grace expires. **Payment and a successful voucher scan do NOT remove it from the
|
present* until grace expires. **Payment and a successful voucher scan do NOT remove it from the
|
||||||
list** — only grace expiry does.
|
list** — only grace expiry does.
|
||||||
|
|
||||||
A session drops off the list once it is **past grace** and EITHER exited OR **paid** (presumed truly
|
A session drops off the list once it is **exited AND past grace** (presumed truly gone). One more
|
||||||
gone). The **paid age-out** is important: a paid session whose walk-back grace lapsed has left, so it
|
state is kept and **flagged**, not dropped:
|
||||||
is omitted **even if no `vehicle_exit` was ever signed**. Without this, a paid car that left via a
|
|
||||||
manual barrier re-open (which historically signed no exit — see below) would linger **forever**
|
- **OVERSTAY — open + paid + past grace, no signed `vehicle_exit`.** A paid transient whose walk-back
|
||||||
(ticket T-397815c0, 2026-06-18). The signed log is untouched — this is purely the list's display
|
grace lapsed. This is **not a system fault and not "stuck"**: the customer paid, then the car stayed
|
||||||
filter (`PayStation.activeSessions()`).
|
beyond the paid window — they **re-parked (a new period began)**, or the car is **faulty/abandoned**.
|
||||||
|
It is **kept in the list with a distinct red `overstay` badge** (not aged out) so the operator
|
||||||
|
reconciles it. This replaces the earlier silent **paid age-out** (revised 2026-06-20): aging these
|
||||||
|
out hid a real problem — the session lingers in **occupancy** (the ledger fold counts it inside, so
|
||||||
|
occupancy and the active-list count diverge), and on a re-scan the exit flow refuses
|
||||||
|
(`exit.refused.graceExpired`). The signed log is untouched — `overstay` is a derived display flag
|
||||||
|
(`PayStation.activeSessions()` and `lookup()`), as the age-out was. The occupancy/active-list gap is
|
||||||
|
now explained by these named rows rather than an unbounded counter.
|
||||||
|
|
||||||
|
> **Naming history (2026-06-20).** First shipped as `stuck` / *i ngecur*. Renamed to `overstay` /
|
||||||
|
> *tej afatit* the same day: "stuck" wrongly implied a system fault trapping the customer, when in
|
||||||
|
> fact a **new parking period has begun**. The label now states the fact (stayed beyond the paid
|
||||||
|
> window), not a presumed cause.
|
||||||
|
|
||||||
|
**No free exit on an overstay (security fix, 2026-06-20).** An overstay is **NOT offered the
|
||||||
|
"Open barrier" action** — its row routes to the pay/exit modal for the **new-period payment**, and
|
||||||
|
`reopenBarrier` **refuses server-side** when a transient's payment grace has expired ("walk-back
|
||||||
|
grace expired — take a top-up payment first"). A stale payment no longer authorizes a free open.
|
||||||
|
*This corrects a hole introduced earlier the same day:* the first cut kept the Open-barrier button
|
||||||
|
on these rows (it gated on `paidAt != null`), which would have let an operator wave out a multi-day
|
||||||
|
overstay for free — exactly the [[threat-model|operator-as-adversary]] path. Subscriptions are never
|
||||||
|
`overstay` (prepaid; no `paidAt`/grace) and keep their assist Open-barrier.
|
||||||
|
|
||||||
|
> **Why flag, not auto-close.** The chosen fix (user, 2026-06-20) keeps the ledger append-only and
|
||||||
|
> the operator in the loop: surfacing the session beats silently synthesizing an exit (which would
|
||||||
|
> mutate occupancy with a weaker audit story) or silently hiding it (which lets occupancy drift
|
||||||
|
> upward until the lot falsely reads "full").
|
||||||
|
|
||||||
|
#### Overstay pricing — a NEW period from grace-expiry (2026-06-20)
|
||||||
|
|
||||||
|
When an overstay is settled, `quote()` prices a **fresh period anchored at grace-expiry**
|
||||||
|
(`paidAt + graceExitMin`) → now, with its **own daily-cap ladder** — NOT the whole stay, and NOT
|
||||||
|
"full stay minus paid". The latter was tried first and was **wrong under a daily cap**: the
|
||||||
|
whole-stay gross plateaus at the cap while prior payments keep pace, so `gross − paid` collapses to
|
||||||
|
**0** and a multi-day overstay would exit **free** (real case: ticket `1245791632490` — entered
|
||||||
|
2026-06-17, paid 330000 with a 100000/day cap, `gross = 330000`, delta = **0 ALL**). Pricing the
|
||||||
|
overstay as a **new session** reflects reality (the car re-parked) and re-accrues the fee
|
||||||
|
(verified: the same ticket owes 20000 ALL for its first half-hour of overstay, not 0). The tariff
|
||||||
|
version stays the one frozen at **entry** (the customer keeps their rate card). The booth modal shows
|
||||||
|
this as a **"New period due"** total with an OVERSTAY status; taking the payment writes a fresh
|
||||||
|
`graceExitMin`, restarting the walk-back window so the car can exit normally. A within-grace paid
|
||||||
|
session is not an overstay (`amountMinor = 0`, non-payable). `Quote` now carries `periodStart` (entry,
|
||||||
|
or grace-expiry for an overstay) and an `overstay` flag.
|
||||||
|
|
||||||
### The one operator action — "Open barrier" (audited re-pulse)
|
### The one operator action — "Open barrier" (audited re-pulse)
|
||||||
|
|
||||||
@@ -106,16 +148,19 @@ For an active session, the operator can open the barrier as a **human interventi
|
|||||||
> *only* way a car left (its walk-back grace had expired, so a normal exit was refused), the session
|
> *only* way a car left (its walk-back grace had expired, so a normal exit was refused), the session
|
||||||
> kept **no exit event** and lingered as "open" forever (ticket T-397815c0). Fix: sign the exit only
|
> kept **no exit event** and lingered as "open" forever (ticket T-397815c0). Fix: sign the exit only
|
||||||
> when the session is **still open**, preserving the no-double-count guarantee for the already-exited
|
> when the session is **still open**, preserving the no-double-count guarantee for the already-exited
|
||||||
> case. The [[#a-session-is-active|paid age-out]] above is the belt-and-braces safety net for any
|
> case. The [[#a-session-is-active|overstay flag]] above is the belt-and-braces visibility net for any
|
||||||
> paid session that still slips through.
|
> paid session that still slips through — it surfaces the orphan for operator reconcile instead of
|
||||||
|
> hiding it.
|
||||||
|
|
||||||
**Guard — paid OR subscription, else no button.** The "Open barrier" action is shown/active for a
|
**Guard — paid-and-in-grace OR subscription, else no button.** The "Open barrier" action is
|
||||||
session that **has a payment** (paid, or paid-and-exited-in-grace) **OR is a [[subscription]]
|
shown/active for a session that has a payment **still within the walk-back grace window** (paid, or
|
||||||
occurrence** (prepaid — the operator must be able to assist a subscriber when the exit reader / card
|
paid-and-exited-in-grace) **OR is a [[subscription]] occurrence** (prepaid — the operator must be
|
||||||
fails). An **unpaid TRANSIENT** open session has **no barrier-open affordance** — the row routes to
|
able to assist a subscriber when the exit reader / card fails). It is **NOT** offered for an **unpaid
|
||||||
the [[#operator-flow|pay/exit modal]] instead. The no-unpaid-bypass rule is enforced structurally
|
TRANSIENT** (no-unpaid-bypass) **nor for an `overstay`** session (grace expired → owes a
|
||||||
(server-side in `reopenBarrier`: `paidAt != null || subscription`). A future reason-required *force
|
top-up). Both route to the [[#operator-flow|pay/exit modal]] instead. Enforced structurally
|
||||||
exit* for genuine disputes would be a separately-audited path — see Open.
|
server-side in `reopenBarrier`: allow only when `subscription` OR (`paidAt != null` AND `now ≤
|
||||||
|
paidAt + graceExitMin`). A future reason-required *force exit* for genuine disputes (car already gone)
|
||||||
|
would be a separately-audited path — see Open.
|
||||||
|
|
||||||
### Subscription occurrences in the booth (built 2026-06-18)
|
### Subscription occurrences in the booth (built 2026-06-18)
|
||||||
|
|
||||||
@@ -131,9 +176,30 @@ This single mechanism covers both edge cases: a **damaged ticket / dead scanner*
|
|||||||
session in the list → pay/exit modal, or if already paid → Open barrier, no scan needed), and a
|
session in the list → pay/exit modal, or if already paid → Open barrier, no scan needed), and a
|
||||||
**phantom-obstacle re-close** (the just-exited car is still in the list within grace → Open barrier).
|
**phantom-obstacle re-close** (the just-exited car is still in the list within grace → Open barrier).
|
||||||
|
|
||||||
|
### Booth filters (built 2026-06-20)
|
||||||
|
|
||||||
|
Both booth lists carry a shared, client-side `FilterBar` (search box + segmented toggles; the active
|
||||||
|
filter shows a `matched/total` count). No new API — filtering is over data already fetched.
|
||||||
|
|
||||||
|
- **Active Sessions**: free-text (ticket id / subscriber holder), a **status** segment
|
||||||
|
(unpaid / paid / exiting / **overstay**), and a **transient vs subscriber** segment.
|
||||||
|
- **Live feed**: free-text (identity / subscriber label / advisory plate), an **event** segment
|
||||||
|
(entry / exit / pay / void / anomaly), a **direction** segment (entry / exit), and a **source**
|
||||||
|
segment — **booth** (operator-initiated, `source: manual`) vs **reader** (device-initiated:
|
||||||
|
wiegand/lpr/qr/ticket). Filters are scoped within the current shift window, as the feed already is.
|
||||||
|
|
||||||
## ⚠ Open question — walk-back grace renews on every payment (voucher overstay)
|
## ⚠ Open question — walk-back grace renews on every payment (voucher overstay)
|
||||||
|
|
||||||
**Found 2026-06-17. Not yet fixed.** Scenario: customer pays at the booth, takes an exit voucher,
|
> **Update 2026-06-20 — pricing half resolved; grace-renewal half still open.** The overstay work
|
||||||
|
> (see [[#overstay-pricing-a-new-period-from-grace-expiry-2026-06-20|Overstay pricing]] above) changed
|
||||||
|
> the money model: an overstay is now priced as a **NEW period from grace-expiry**, *not*
|
||||||
|
> reprice-from-entry. The note below described the older reprice-from-entry behaviour; the **leak-is-
|
||||||
|
> time, not money** analysis still holds for the grace-window side, which is **still unfixed** —
|
||||||
|
> candidate fix #1 below remains the recommendation. (Note: under new-period pricing the "pay a tiny
|
||||||
|
> delta → fresh full window" loop now also re-accrues a fresh fee each cycle, narrowing but not
|
||||||
|
> closing the time leak.)
|
||||||
|
|
||||||
|
**Found 2026-06-17.** Scenario: customer pays at the booth, takes an exit voucher,
|
||||||
then dawdles past the walk-back grace before reaching the exit.
|
then dawdles past the walk-back grace before reaching the exit.
|
||||||
|
|
||||||
What the code does today (`exit-flow.ts`, `pay-station.ts`):
|
What the code does today (`exit-flow.ts`, `pay-station.ts`):
|
||||||
|
|||||||
+55
@@ -948,3 +948,58 @@ Reworked the ANPR TRIGGER per the real design goal: when a transient pushes the
|
|||||||
## [2026-06-19] feat | Surface recognized plate in the booth UI (SnapshotStrip)
|
## [2026-06-19] feat | Surface recognized plate in the booth UI (SnapshotStrip)
|
||||||
|
|
||||||
Made the ANPR plate VIEWABLE (it was saved but had no UI). Extended GET /api/snapshots/by-identity/:identity to also query device_events kind:"read" for that identity and return plates[] (plate, confidence, region, direction, snapshotId, at) alongside the existing snapshots + failures. The SnapshotStrip now renders each recognized plate as a cyan "Plate: AA558EE 100%" chip above the images (deduped by plate+direction; title shows region + time) — so it appears in BOTH the booth event-detail modal and the pay modal, beside the evidence photo, no separate screen. session:read gated (same as snapshots). i18n pay.plate sq+en. VERIFIED: by-identity returns plates[] for a seeded read (status 200, {plate:AA558EE, confidence:0.999, region:Albania, direction:entry, snapshotId}). Build+lint green. Updated [[opencv-anpr-service]].
|
Made the ANPR plate VIEWABLE (it was saved but had no UI). Extended GET /api/snapshots/by-identity/:identity to also query device_events kind:"read" for that identity and return plates[] (plate, confidence, region, direction, snapshotId, at) alongside the existing snapshots + failures. The SnapshotStrip now renders each recognized plate as a cyan "Plate: AA558EE 100%" chip above the images (deduped by plate+direction; title shows region + time) — so it appears in BOTH the booth event-detail modal and the pay modal, beside the evidence photo, no separate screen. session:read gated (same as snapshots). i18n pay.plate sq+en. VERIFIED: by-identity returns plates[] for a seeded read (status 200, {plate:AA558EE, confidence:0.999, region:Albania, direction:entry, snapshotId}). Build+lint green. Updated [[opencv-anpr-service]].
|
||||||
|
|
||||||
|
## [2026-06-20] feat | Flag stuck sessions + booth filters (session list & live feed)
|
||||||
|
|
||||||
|
Replaced the silent paid age-out in PayStation.activeSessions() with a derived `stuck` flag: an
|
||||||
|
open + paid + past-grace session with no signed vehicle_exit is no longer dropped — it stays listed
|
||||||
|
with a red "stuck" badge so the operator can reconcile (top-up exit / void). Root cause surfaced via
|
||||||
|
the live ledger: 4 such orphans (5717802544704, 1245791632490, 7985713986045, 9340902468934) each
|
||||||
|
entry=1/exit=0/pay=1, grace=5min lapsed; they linger in the occupancy fold (so occupancy diverges
|
||||||
|
from the active-list count) and a re-scan re-quotes the tariff from entry (paid customer charged
|
||||||
|
again). Signed log untouched; subscriptions never stuck (no paidAt). This removed the earlier
|
||||||
|
unbounded "presumed-left (N)" counter (occupancy − sessions), which had been growing.
|
||||||
|
|
||||||
|
Added a shared client-side FilterBar (ui/FilterBar.tsx: search + SegGroup toggles, matched/total
|
||||||
|
count). Active Sessions: search (ticket/holder) + status (unpaid/paid/exiting/stuck) + transient-vs-
|
||||||
|
subscriber. Live feed: search (identity/subscriber/plate) + event (entry/exit/pay/void/anomaly) +
|
||||||
|
direction (entry/exit) + source (booth=manual vs reader=device). No new API. Exit grace re-scan
|
||||||
|
logic UNCHANGED (still refuse + send to booth — user choice). Build+lint green across the monorepo.
|
||||||
|
Updated [[booth-exit-flow]].
|
||||||
|
|
||||||
|
## [2026-06-20] fix | No free exit on overstay (stuck session) + top-up pricing
|
||||||
|
|
||||||
|
SECURITY FIX correcting same-day stuck-flag work. A stuck session (paid + walk-back grace expired +
|
||||||
|
no signed exit) is AMBIGUOUS — the car may have left OR be overstaying inside. The first cut kept the
|
||||||
|
"Open barrier" button on these rows (gated on paidAt != null), which would let an operator wave out a
|
||||||
|
2-day overstay for free — the operator-as-adversary path. Fix: reopenBarrier now refuses a transient
|
||||||
|
whose payment grace has expired (allow only subscription OR paid-and-within-grace), enforced
|
||||||
|
server-side (exit-flow.ts), mirrored in the UI (no button on s.stuck → routes to pay/exit modal).
|
||||||
|
Verified against the live ledger: ticket 5717802544704 (entered 73.9h ago, paid 200000, grace 5min)
|
||||||
|
computes stuck=true and reopenBarrier REFUSES it.
|
||||||
|
|
||||||
|
Top-up pricing — "full stay minus paid" (user choice): quote() now returns grossMinor (whole stay
|
||||||
|
entry→now) and paidMinor (fold of prior signed payment amounts), with amountMinor = max(0, gross −
|
||||||
|
paid) — the delta only, never the full stay twice. lookup()/SessionLookup gained `stuck`; the pay
|
||||||
|
modal shows OVERSTAY status + "Top-up due" + a hint, and canPay now allows payment for a stuck
|
||||||
|
session. Taking the top-up restarts grace so the car exits normally. i18n pay.overstay/overstayHint/
|
||||||
|
topUp in sq+en. Partially resolves the grace-overstay Open question (the amount); whether to bill the
|
||||||
|
overstay delta-from-grace vs full-minus-paid left open. Build+lint green. Updated [[booth-exit-flow]].
|
||||||
|
|
||||||
|
## [2026-06-20] fix | Rename stuck→overstay + price overstay as a NEW period (fixes ALL 0)
|
||||||
|
|
||||||
|
Two user-driven corrections to the same-day overstay work. (1) NAMING: "stuck"/"i ngecur" wrongly
|
||||||
|
implied a system fault trapping the customer — but a paid-then-grace-expired car means a NEW parking
|
||||||
|
period began (re-parked) or the car is faulty/abandoned. Renamed the flag + badge + filter +
|
||||||
|
SessionLookup/ActiveSession field to `overstay` / "tej afatit" across server + web + i18n.
|
||||||
|
|
||||||
|
(2) PRICING BUG: "full stay minus paid" collapsed to 0 under a daily cap — ticket 1245791632490
|
||||||
|
(entered 06-17, paid 330000, cap 100000/day) had gross=330000, so delta=0 → "Diferenca për pagesë
|
||||||
|
ALL 0", a free multi-day exit. Fix (user choice): quote() now prices an overstay as a NEW period
|
||||||
|
anchored at grace-expiry (paidAt+graceExitMin)→now with its own daily-cap ladder, NOT entry→now. The
|
||||||
|
tariff version stays the one frozen at entry. Quote gained periodStart + overstay; removed
|
||||||
|
grossMinor/paidMinor. Verified: 1245791632490 now owes 20000 ALL (first half-hour of overstay), not 0.
|
||||||
|
pay modal: "New period due"/OVERSTAY; handlePayAndExit now charges when canPay (was: only if
|
||||||
|
!alreadyPaid — would have skipped the overstay charge). i18n pay.overstay/overstayHint/topUp +
|
||||||
|
booth.badgeOverstay*/fStatusOverstay rewritten in sq+en. Build+lint green. Updated [[booth-exit-flow]]
|
||||||
|
(overstay section + naming history + partial-resolution note on the grace-renewal open question).
|
||||||
|
|||||||
Reference in New Issue
Block a user