feat(booth): pay-on-foot at the booth — ticket lookup, pay, exit, voucher, snapshots

Backend: PayStation.lookup (session view + quote in one read); ExitFlow.exitForBooth
reuses the reader path's paid+grace validation (no booth-only unpaid bypass) and
signs vehicle_exit + pulses an exit relay; printExitVoucher reprints the paid ticket
id barcode; site_config.exit_voucher_default (migration 0002) drives the default.
Routes: GET /api/session/:id, POST /api/exit, POST /api/voucher.

Web: BoothPayModal (entry/now/duration/total, tender, 'Printo biletë dalje'),
SnapshotStrip (entry/exit evidence), api.ts client fns, SiteSettings toggle.
This commit is contained in:
2026-06-18 11:05:10 +02:00
parent 9956488fd5
commit 06dab1e790
14 changed files with 1891 additions and 24 deletions
+234
View File
@@ -0,0 +1,234 @@
import { useState } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
boothExit,
fetchSiteConfig,
lookupSession,
paySession,
printVoucher,
type SessionLookup,
} from "./api.js";
import { qk } from "./lib/query.js";
import { formatDuration, formatMoney, formatTime } from "./lib/format.js";
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
// session (entry, exit=now, duration, total owed) + entry/exit snapshots, takes
// payment, then EITHER prints an exit voucher (customer self-exits at a distant
// exit) OR fires the exit immediately (booth at/near the exit) — controlled by a
// checkbox defaulting from site_config.exitVoucherDefault. See booth-exit-flow.md.
type Phase = "review" | "paying" | "finishing" | "done" | "error";
export function BoothPayModal({ identity, onClose }: { identity: string; onClose: () => void }) {
const qc = useQueryClient();
const session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) });
const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig });
const [tender, setTender] = useState<"cash" | "card">("cash");
const [printVoucherChecked, setPrintVoucherChecked] = useState<boolean | null>(null);
const [phase, setPhase] = useState<Phase>("review");
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);
const s: SessionLookup | undefined = session.data;
// Checkbox default comes from config the first time it loads; operator can toggle.
const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false;
const alreadyPaid = s?.paidAt != null;
const canPay = s?.found && s.open && !alreadyPaid;
async function handlePayAndExit() {
if (!s) return;
setError(null);
try {
// 1. Take payment (unless already paid — e.g. paid earlier at a kiosk).
if (!alreadyPaid) {
setPhase("paying");
await paySession(identity, tender);
}
// 2. Voucher OR immediate exit.
setPhase("finishing");
if (voucher) {
const r = await printVoucher(identity);
setResult(`Exit voucher printed on ${r.printedBy}. Customer self-exits at the exit.`);
} else {
const r = await boothExit(identity);
setResult(
r.opened
? "Paid — barrier opened. Car may exit."
: `Paid and exit recorded, but the barrier did not open: ${r.reason ?? "open manually"}.`,
);
}
// Refresh the live views.
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
setPhase("done");
} catch (e) {
setError((e as Error).message);
setPhase("error");
}
}
return (
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/70" />
<Dialog.Content
className="fixed left-1/2 top-1/2 z-50 w-[560px] max-w-[95vw] -translate-x-1/2 -translate-y-1/2 rounded-term border border-term-border bg-term-panel font-mono text-term-text shadow-2xl"
aria-describedby={undefined}
>
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
Ticket {identity}
</Dialog.Title>
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close">
✕
</Dialog.Close>
</div>
<div className="flex flex-col gap-3 p-4">
{session.isLoading && <div className="text-term-muted">looking up…</div>}
{s && !s.found && (
<div className="rounded-term border border-term-red px-3 py-2 text-term-red">
No session found for this ticket.
</div>
)}
{s && s.found && !s.open && (
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
This session is already closed (exited {formatTime(s.exitedAt)}).
</div>
)}
{s && s.found && s.open && (
<>
{/* Session figures */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
<Row label="Entry" value={formatTime(s.enteredAt)} />
<Row label="Now" value={formatTime(new Date().toISOString())} />
<Row label="Duration" value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"} />
<Row
label="Status"
value={alreadyPaid ? "PAID" : "UNPAID"}
valueClass={alreadyPaid ? "text-term-green" : "text-term-amber"}
/>
</div>
{/* Total */}
<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">Total</span>
<span className="text-3xl font-bold text-term-cyan">
{s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency)
: alreadyPaid
? "paid"
: "no tariff"}
</span>
</div>
{/* Snapshots */}
<SnapshotStrip identity={identity} />
{phase !== "done" && (
<>
{/* Tender */}
{canPay && (
<div className="flex items-center gap-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">Tender</span>
{(["cash", "card"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTender(t)}
className={`rounded-term border px-3 py-1 text-[12px] uppercase tracking-wider ${
tender === t
? "border-term-amber text-term-amber"
: "border-term-border text-term-muted hover:text-term-text"
}`}
>
{t}
</button>
))}
</div>
)}
{/* Voucher checkbox (default from site config) */}
<label className="flex items-center gap-2 text-[12px]">
<input
type="checkbox"
checked={voucher}
onChange={(e) => setPrintVoucherChecked(e.target.checked)}
/>
Printo biletë dalje
<span className="text-term-muted">(customer self-exits at the exit)</span>
</label>
</>
)}
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
{result && (
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-1">
{phase === "done" ? (
<button
type="button"
onClick={onClose}
className="rounded-term border border-term-amber px-4 py-1.5 text-[12px] uppercase tracking-wider text-term-amber"
>
Close
</button>
) : (
<>
<button
type="button"
onClick={onClose}
className="rounded-term border border-term-border px-3 py-1.5 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text"
>
Cancel
</button>
<button
type="button"
onClick={handlePayAndExit}
disabled={phase === "paying" || phase === "finishing"}
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
>
{phase === "paying"
? "taking payment…"
: phase === "finishing"
? voucher
? "printing voucher…"
: "opening…"
: alreadyPaid
? voucher
? "Print voucher"
: "Open barrier"
: voucher
? "Pay + print voucher"
: "Pay + open barrier"}
</button>
</>
)}
</div>
</>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
return (
<div className="flex items-baseline justify-between">
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
<span className={`text-sm ${valueClass}`}>{value}</span>
</div>
);
}