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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user