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
+119 -49
View File
@@ -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>
);
+41 -7
View File
@@ -52,9 +52,16 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
const alreadyPaid = s?.paidAt != null;
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
// 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() {
if (!s) return;
@@ -103,8 +110,11 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
if (!s) return;
setError(null);
try {
// 1. Take payment (unless already paid — e.g. paid earlier at a kiosk).
if (!alreadyPaid) {
// 1. Take payment. For a first stay this is the only charge; for an OVERSTAY the
// 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");
await paySession(identity, tender);
}
@@ -221,15 +231,32 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
/>
<Row
label={t("pay.statusLabel")}
value={isSubscription ? t("pay.subscription") : alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
valueClass={isSubscription ? "text-term-cyan" : alreadyPaid ? "text-term-green" : "text-term-amber"}
value={
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>
{/* 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">
<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 className="text-3xl font-bold text-term-cyan">
{isSubscription
@@ -249,6 +276,13 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
</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 */}
<SnapshotStrip identity={identity} />
+92 -9
View File
@@ -12,6 +12,7 @@ import { BoothPayModal } from "./BoothPayModal.js";
import { ActiveSessions } from "./ActiveSessions.js";
import { Modal } from "./ui/Modal.js";
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
import { renderReason } from "./lib/reason.js";
// 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" },
};
// 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 {
// Local time-of-day, terminal style. Defensive against a bad timestamp.
const d = new Date(iso);
@@ -372,6 +394,12 @@ export function BoothScreen() {
// The ledger event open in the read-only detail modal (null = closed).
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.
const liveOcc = useLiveStore((s) => s.occupancy);
const liveFeed = useLiveStore((s) => s.feed);
@@ -385,11 +413,45 @@ export function BoothScreen() {
const seen = new Set(liveFeed.map((e) => e.id));
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
const merged = [...liveFeed, ...history].slice(0, 200);
const events =
const scoped =
shiftOpen && 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 (
<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. */}
@@ -417,19 +479,40 @@ export function BoothScreen() {
title={t("booth.liveFeed")}
right={
<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>
}
className="min-h-0"
>
<div className="h-full 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") : t("booth.noEventsYet")}</div>
) : (
events.map((e) => <EventRow key={e.id} e={e} onOpen={setDetailEvent} />)
<div className="flex h-full flex-col">
{shiftOpen && (
<FilterBar search={feedSearch} onSearch={setFeedSearch} searchPlaceholder={t("booth.filterSearchFeed")}>
<SegGroup
value={feedType}
options={feedTypeOpts}
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>
</Panel>
+7
View File
@@ -694,6 +694,9 @@ export interface SessionLookup {
currency: string | null;
withinGrace: boolean;
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). */
subscription: boolean;
subscriptionId: string | null;
@@ -717,6 +720,10 @@ export interface ActiveSession {
currency: string | null;
withinGrace: boolean;
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). */
subscription: boolean;
subscriptionId: string | null;
+28 -2
View File
@@ -59,11 +59,11 @@ export const en: Catalog = {
devices: {
footerTitle: "Devices",
none: "No devices configured.",
catAccess: "Barrier",
catAccess: "Relay",
catReader: "Reader",
catCamera: "Camera",
catPrinter: "Printer",
catVision: "Vision",
catVision: "ANPR",
// Role/direction suffixes for the chip label (e.g. "Reader entry").
role: {
entry: "entry",
@@ -101,6 +101,29 @@ export const en: Catalog = {
activeSessions: "Active sessions",
insideCount: "inside",
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",
openBarrier: "Open barrier",
openBarrierTitle: "Human-intervention barrier open (audited)",
@@ -523,6 +546,9 @@ export const en: Catalog = {
statusLabel: "Status",
paid: "PAID",
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",
noTariff: "no tariff",
tender: "Tender",
+28 -2
View File
@@ -61,11 +61,11 @@ export const sq = {
devices: {
footerTitle: "Pajisjet",
none: "Asnjë pajisje e konfiguruar.",
catAccess: "Barriera",
catAccess: "Rele",
catReader: "Lexuesi",
catCamera: "Kamera",
catPrinter: "Printer",
catVision: "Vizioni",
catVision: "ANPR",
// Role/direction suffixes for the chip label (e.g. "Lexuesi hyrje").
role: {
entry: "hyrje",
@@ -103,6 +103,29 @@ export const sq = {
activeSessions: "Sesionet aktive",
insideCount: "brenda",
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",
openBarrier: "Hap barrierën",
openBarrierTitle: "Hap barrierën manualisht",
@@ -537,6 +560,9 @@ export const sq = {
statusLabel: "Statusi",
paid: "PAGUAR",
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",
noTariff: "pa tarifë",
tender: "Mënyra",
+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>
);
}