import { useState } from "react"; import { useTranslation } from "react-i18next"; import * as Dialog from "@radix-ui/react-dialog"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { boothExit, fetchSiteConfig, lookupSession, openShift, paySession, printReceipt, printVoucher, reopenBarrier, type SessionLookup, } from "./api.js"; import { qk } from "./lib/query.js"; import { useShift } from "./lib/use-shift.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 { t } = useTranslation(); const qc = useQueryClient(); const session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) }); const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig }); // A shift must be open (and mine) before any pay/exit/voucher action — the booth // money path is gated. The server enforces this too (409 no_shift); the modal // surfaces it up front and offers a one-click open. See wiki/concepts/shift.md. const { isOpen: shiftOpen, isMine: shiftMine, blockedByOther, heldBy } = useShift(); const shiftReady = shiftOpen && shiftMine; const [tender, setTender] = useState<"cash" | "card">("cash"); const [printVoucherChecked, setPrintVoucherChecked] = useState(null); const [phase, setPhase] = useState("review"); const [error, setError] = useState(null); const [result, setResult] = useState(null); const [openingShift, setOpeningShift] = useState(false); const [reprinting, setReprinting] = useState(false); 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 isSubscription = s?.subscription === 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; async function handleOpenBarrier() { if (!s) return; setError(null); setPhase("finishing"); try { const r = await reopenBarrier(identity); setResult(r.opened ? t("pay.subBarrierOpened") : t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") })); void qc.invalidateQueries({ queryKey: qk.events }); void qc.invalidateQueries({ queryKey: qk.activeSessions }); setPhase("done"); } catch (e) { setError((e as Error).message); setPhase("error"); } } async function handleOpenShift() { setOpeningShift(true); setError(null); try { await openShift(); void qc.invalidateQueries({ queryKey: qk.shift }); void qc.invalidateQueries({ queryKey: qk.events }); } catch (e) { setError((e as Error).message); } finally { setOpeningShift(false); } } async function handleReprintReceipt() { setReprinting(true); setError(null); try { const r = await printReceipt(identity); setResult(t("pay.receiptReprinted", { printer: r.printedBy })); } catch (e) { setError((e as Error).message); } finally { setReprinting(false); } } 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) { // The voucher slip carries the payment detail + barcode + grace. const r = await printVoucher(identity); setResult(t("pay.voucherPrinted", { printer: r.printedBy })); } else { const r = await boothExit(identity); // No voucher → auto-print a standalone payment receipt for transparency. // Best-effort: a printer fault must NOT block the exit that already happened; // the operator can reprint from the done screen. let receiptNote = ""; try { await printReceipt(identity); } catch { receiptNote = ` ${t("pay.receiptPrintFailed")}`; } setResult( (r.opened ? t("pay.paidBarrierOpened") : t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") })) + receiptNote, ); } // 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 ( !o && onClose()}>
{isSubscription ? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}` : `${t("pay.ticket")} ${identity}`} ✕
{/* Shift gate — block all actions until THIS operator has a shift open. Another operator's open shift can't be operated under (no shared till); only an "open mine" path when no shift is open at all. */} {!shiftReady && (
{blockedByOther ? ( <>
{t("shift.gateOtherTitle")}
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
) : ( <>
{t("shift.gateTitle")}
{t("shift.gateBody")}
)}
)} {session.isLoading &&
{t("pay.lookingUp")}
} {s && !s.found && (
{t("pay.noSessionFound")}
)} {s && s.found && !s.open && (
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
)} {s && s.found && s.open && ( <> {/* Session figures */}
{/* Total — a subscription is prepaid (no amount); show a badge. */}
{isSubscription ? t("pay.plan") : t("pay.total")} {isSubscription ? t("pay.prepaid") : s.amountMinor != null && s.currency ? formatMoney(s.amountMinor, s.currency) : alreadyPaid ? t("booth.badgePaid") : t("pay.noTariff")}
{/* For a subscription, explain the only available action. */} {isSubscription && (
{t("pay.subAssistHint")}
)} {/* Snapshots */} {phase !== "done" && !isSubscription && ( <> {/* Tender */} {canPay && (
{t("pay.tender")} {(["cash", "card"] as const).map((tn) => ( ))}
)} {/* Voucher checkbox (default from site config) */} )} {error &&
{error}
} {result && (
{result}
)} {/* Actions */}
{phase === "done" ? ( <> {/* Reprint the payment receipt (slip jammed / customer asks). Only for a charged session — a subscription has no payment. */} {!isSubscription && ( )} ) : ( <> {isSubscription ? ( // Prepaid — the only action is the audited barrier open (assist // a faulty exit reader / missing card). Gated on an open shift. ) : ( )} )}
)}
); } function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) { return (
{label} {value}
); }