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:
+119
-49
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
|
||||
@@ -6,6 +6,7 @@ import { qk } from "./lib/query.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { formatDuration, formatRelativeDateTime } from "./lib/format.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-
|
||||
// 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),
|
||||
// - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse.
|
||||
// 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.
|
||||
|
||||
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.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.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
|
||||
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 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) {
|
||||
setReopenMsg(null);
|
||||
@@ -68,62 +116,84 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
title={t("booth.activeSessions")}
|
||||
right={
|
||||
<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>
|
||||
}
|
||||
className="min-h-0 flex-1"
|
||||
>
|
||||
<div className="h-full overflow-y-auto pr-1">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="text-term-muted">{isLoading ? t("common.loading") : t("booth.noActiveSessions")}</div>
|
||||
) : (
|
||||
sessions.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
|
||||
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>
|
||||
<div className="flex h-full flex-col">
|
||||
<FilterBar search={search} onSearch={setSearch} searchPlaceholder={t("booth.filterSearchSessions")}>
|
||||
<SegGroup value={status} options={statusOpts} onChange={setStatus} allLabel={t("booth.filterAll")} />
|
||||
<SegGroup value={kind} options={kindOpts} onChange={setKind} allLabel={t("booth.filterAll")} />
|
||||
</FilterBar>
|
||||
|
||||
{/* Open barrier — PAID transient OR a SUBSCRIPTION (prepaid). An
|
||||
unpaid transient has no button (no-unpaid-bypass). */}
|
||||
{s.paidAt || s.subscription ? (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
{filtered.length === 0 ? (
|
||||
<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
|
||||
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")}
|
||||
onClick={() => onPick(s.identity)}
|
||||
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
|
||||
title={t("booth.openPayExit")}
|
||||
>
|
||||
{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>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{/* Open barrier — PAID-and-still-in-grace transient OR a SUBSCRIPTION
|
||||
(prepaid). NOT an OVERSTAY session: its grace has expired, so the car
|
||||
owes a top-up — the row routes to the pay/exit modal instead (no
|
||||
free overstay exit). An unpaid transient also has no button
|
||||
(no-unpaid-bypass). Mirrors reopenBarrier's server-side guard. */}
|
||||
{(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>
|
||||
</Panel>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user