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:
2026-06-20 11:48:54 +02:00
parent 918f76fbef
commit a4712774ab
11 changed files with 620 additions and 110 deletions
+69
View File
@@ -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>
);
}