a9ccf9e20c
Car Wash — the pilot venue module (wiki/decisions/venue-modules.md): - Master data (categories × services price matrix) at /setup/carwash; the desk at /wash (ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void; Finished list). Orders freeze names + price; their life is signed (carwash_order, carwash_payment). Migration 0027. - Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed config_change on a flip) — no per-order radio; a stale client is refused (409). - Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash signs the $0 parking payment so the exit reader releases the car. - "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash price off the fee (floored at 0), resolved at done and anchored at the order's intake (the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for the wash. Long durations render y/d/h/m. Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills): - TillId booth|carwash; every money event names its till (absent = booth, so the chain re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports, vouchers, carry-forward. A bay payment needs the carwash shift. - Working a till needs that till's module permission (manifest tillPermission; 403 till_forbidden); /api/shift/tills lists only the role's tills. - Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every open shift with till badges + filter; drawer hub switches tills. Modules: landing per module (index route resolves booth → module landing → shifts → profile); guards bounce to "/", /booth needs session:read. Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky backup test under the parallel run). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
742 lines
36 KiB
TypeScript
742 lines
36 KiB
TypeScript
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,
|
||
can,
|
||
fetchSiteConfig,
|
||
lookupSession,
|
||
openShift,
|
||
paySession,
|
||
printReceipt,
|
||
printVoucher,
|
||
reopenBarrier,
|
||
voidTicket,
|
||
type SessionLookup,
|
||
} from "./api.js";
|
||
import { rootRoute } from "./router.js";
|
||
import { qk } from "./lib/query.js";
|
||
import { useShift } from "./lib/use-shift.js";
|
||
import { formatDuration, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||
import { Spinner } from "./ui/Spinner.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<boolean | null>(null);
|
||
const [phase, setPhase] = useState<Phase>("review");
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [result, setResult] = useState<string | null>(null);
|
||
const [openingShift, setOpeningShift] = useState(false);
|
||
const [reprinting, setReprinting] = useState(false);
|
||
// For a PREPAID subscriber with nothing owed, the audited manual barrier open
|
||
// (assist a faulty reader / lost card) is no longer the default action — the
|
||
// operator reveals it explicitly so the modal isn't an always-on "open" button.
|
||
const [assistRevealed, setAssistRevealed] = useState(false);
|
||
// For a subscriber WINDOW CHARGE, payment and the barrier open are two steps: pay
|
||
// first, then the modal reveals "Open barrier". This flips true once paid.
|
||
const [windowPaid, setWindowPaid] = useState(false);
|
||
// Cancel (void) a wrongly-printed ticket: a small reason prompt, then a signed void.
|
||
const { user } = rootRoute.useRouteContext();
|
||
const canVoid = can(user, "event:void");
|
||
const [voiding, setVoiding] = useState(false); // reason prompt revealed
|
||
// Plate-swap: set when boothExit returns swap_suspected. Holds the detail for the warning
|
||
// panel; the operator must consciously "Override & release". See plate-reconciliation.md.
|
||
const [swap, setSwap] = useState<{ plate: string; otherIdentity: string; otherEnteredAt: string | null } | null>(null);
|
||
const [voidReason, setVoidReason] = useState("");
|
||
|
||
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;
|
||
// 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;
|
||
// CLOSED-WITHIN-GRACE: a paid transient whose exit was already signed but the barrier
|
||
// didn't confirm — it lingers in the active list until grace runs out (the "phantom
|
||
// re-close" / damaged-ticket case). `s.open` is false, so it's not payable and not the
|
||
// normal review flow; the only action is an audited manual re-pulse of the barrier.
|
||
// (A grace-EXPIRED closed session falls through to the plain "already closed" notice.)
|
||
const closedWithinGrace = !!(s?.found && !s.open && s.withinGrace && !isSubscription);
|
||
// A subscription is normally prepaid (never charged). EXCEPTION: a time-window plan can
|
||
// owe an out-of-window TARIFF-BRIDGE charge (early entry / late exit) — lookup() returns
|
||
// it as s.amountMinor, and exit is GATED until it's paid. So a subscription IS payable
|
||
// when it has an amount due. Otherwise the only action is an audited assist-open.
|
||
const subWindowDue = !!(isSubscription && (s?.amountMinor ?? 0) > 0);
|
||
// Allow pay for an unpaid transient, an overstay top-up, or a subscriber window charge.
|
||
const canPay = !!(
|
||
shiftReady &&
|
||
s?.found &&
|
||
s.open &&
|
||
((!alreadyPaid && !isSubscription) || isOverstay || subWindowDue)
|
||
);
|
||
|
||
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");
|
||
}
|
||
}
|
||
|
||
// Subscriber out-of-window charge: take the payment, but DON'T exit yet. The
|
||
// barrier open is the operator's explicit second step (so the flow reads:
|
||
// pay → then Open barrier), mirroring the two-step the operator asked for.
|
||
async function handlePaySubscriptionWindow() {
|
||
if (!s) return;
|
||
setError(null);
|
||
setPhase("paying");
|
||
try {
|
||
await paySession(identity, tender);
|
||
setWindowPaid(true);
|
||
setPhase("review");
|
||
void qc.invalidateQueries({ queryKey: ["session", identity] });
|
||
void qc.invalidateQueries({ queryKey: qk.events });
|
||
} 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);
|
||
}
|
||
}
|
||
|
||
// A wrongly-printed ticket is cancellable only while it's a TRANSIENT, UNPAID, OPEN
|
||
// session (a subscription is closed via its own flow; a paid ticket is a refund). The
|
||
// server enforces all of this too; the UI just hides the action when it can't apply.
|
||
const canCancel = !!(canVoid && shiftReady && s?.found && s.open && !isSubscription && !alreadyPaid);
|
||
|
||
async function handleVoidTicket() {
|
||
const reason = voidReason.trim();
|
||
if (!reason) return;
|
||
setError(null);
|
||
setPhase("finishing");
|
||
try {
|
||
await voidTicket(identity, reason);
|
||
setResult(t("pay.ticketCancelled"));
|
||
void qc.invalidateQueries({ queryKey: qk.events });
|
||
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||
setPhase("done");
|
||
} catch (e) {
|
||
setError((e as Error).message);
|
||
setPhase("error");
|
||
}
|
||
}
|
||
|
||
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(override = false) {
|
||
if (!s) return;
|
||
setError(null);
|
||
try {
|
||
// 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).
|
||
// On an OVERRIDE re-submit the payment already happened; don't double-charge.
|
||
if (canPay && !override) {
|
||
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, override);
|
||
// PLATE-SWAP suspected → don't exit; surface the warning + offer an override.
|
||
if (!r.ok) {
|
||
setSwap({ plate: r.plate, otherIdentity: r.otherIdentity, otherEnteredAt: r.otherEnteredAt });
|
||
setPhase("review");
|
||
return;
|
||
}
|
||
setSwap(null);
|
||
// 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 (
|
||
<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-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
|
||
{isSubscription
|
||
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
|
||
: `${t("pay.ticket")} ${identity}`}
|
||
</Dialog.Title>
|
||
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label={t("common.close")}>
|
||
✕
|
||
</Dialog.Close>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-3 p-4">
|
||
{/* 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 && (
|
||
<div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2">
|
||
{blockedByOther ? (
|
||
<>
|
||
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
|
||
{t("shift.gateOtherTitle")}
|
||
</div>
|
||
<div className="mt-1 text-[0.75rem] text-term-text">
|
||
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
|
||
{t("shift.gateTitle")}
|
||
</div>
|
||
<div className="mt-1 text-[0.75rem] text-term-text">{t("shift.gateBody")}</div>
|
||
<button
|
||
type="button"
|
||
onClick={handleOpenShift}
|
||
disabled={openingShift}
|
||
className="btn btn-go btn-sm mt-2"
|
||
>
|
||
{openingShift ? (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<Spinner /> {t("shift.opening")}
|
||
</span>
|
||
) : (
|
||
t("shift.openNow")
|
||
)}
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{session.isLoading && <div className="text-term-muted">{t("pay.lookingUp")}</div>}
|
||
|
||
{s && !s.found && (
|
||
<div className="rounded-term border border-term-red px-3 py-2 text-term-red">
|
||
{t("pay.noSessionFound")}
|
||
</div>
|
||
)}
|
||
|
||
{s && s.found && !s.open && !closedWithinGrace && (
|
||
// A fully-closed session (exited, grace expired): no action to take, but the
|
||
// operator may still need to REVIEW the evidence (entry/exit snapshots + plate)
|
||
// — e.g. a dispute about a car that just left. Show the closed notice, the
|
||
// figures, and the snapshot strip read-only. No tender / voucher / open here.
|
||
<>
|
||
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||
{t("pay.alreadyClosed", { time: formatRelativeDateTime(s.exitedAt, t, { seconds: true }) })}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
|
||
<Row label={t("pay.exit")} value={formatRelativeDateTime(s.exitedAt, t, { seconds: true })} />
|
||
<Row
|
||
label={t("pay.duration")}
|
||
value={
|
||
s.enteredAt ? formatDuration(s.enteredAt, s.exitedAt ?? new Date().toISOString()) : "—"
|
||
}
|
||
/>
|
||
{alreadyPaid && s.paidMinor != null && s.paidCurrency && (
|
||
<Row label={t("pay.paidAmount")} value={formatMoney(s.paidMinor, s.paidCurrency)} valueClass="text-term-green" />
|
||
)}
|
||
</div>
|
||
|
||
<SnapshotStrip identity={identity} />
|
||
</>
|
||
)}
|
||
|
||
{s && s.found && (s.open || closedWithinGrace) && (
|
||
<>
|
||
{/* Session figures */}
|
||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
|
||
{/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
|
||
<Row
|
||
label={closedWithinGrace ? t("pay.exit") : t("pay.now")}
|
||
value={formatRelativeDateTime(
|
||
closedWithinGrace ? s.exitedAt : new Date().toISOString(),
|
||
t,
|
||
{ seconds: true },
|
||
)}
|
||
/>
|
||
<Row
|
||
label={t("pay.duration")}
|
||
value={
|
||
s.enteredAt
|
||
? formatDuration(
|
||
s.enteredAt,
|
||
(closedWithinGrace ? s.exitedAt : null) ?? new Date().toISOString(),
|
||
)
|
||
: "—"
|
||
}
|
||
/>
|
||
<Row
|
||
label={t("pay.statusLabel")}
|
||
value={
|
||
isSubscription
|
||
? t("pay.subscription")
|
||
: isOverstay
|
||
? t("pay.overstay")
|
||
: closedWithinGrace
|
||
? t("pay.closedWithinGrace")
|
||
: alreadyPaid
|
||
? t("pay.paid")
|
||
: t("pay.unpaid")
|
||
}
|
||
valueClass={
|
||
isSubscription
|
||
? "text-term-cyan"
|
||
: isOverstay
|
||
? "text-term-red"
|
||
: closedWithinGrace
|
||
? "text-term-amber"
|
||
: alreadyPaid
|
||
? "text-term-green"
|
||
: "text-term-amber"
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
{/* Merchant validations (bar/lavazh): the gross fee + one line per
|
||
discount — the Total below is the NET the customer pays. The lines
|
||
ride the quote (SessionLookup.validationLines) and reprint on the
|
||
receipt. See wiki/concepts/validation-discounts.md. */}
|
||
{!isSubscription &&
|
||
(s.validationLines ?? []).length > 0 &&
|
||
s.currency != null &&
|
||
s.amountMinor != null && (
|
||
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
|
||
<div className="flex justify-between text-term-text">
|
||
<span>{t("val.gross")}</span>
|
||
<span className="tabular-nums">
|
||
{formatMoney(s.grossMinor ?? s.amountMinor, s.currency)}
|
||
</span>
|
||
</div>
|
||
{(s.validationLines ?? []).map((v, i) => (
|
||
<div key={i} className="flex justify-between text-term-green">
|
||
<span>{v.label}</span>
|
||
<span className="tabular-nums">−{formatMoney(v.discountMinor, s.currency!)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Module charges folded into the settlement (e.g. a car wash ordered
|
||
with "pay at booth") — one "+" line each; the Total below includes
|
||
them. See wiki/decisions/venue-modules.md. */}
|
||
{!isSubscription &&
|
||
(s.chargeLines ?? []).length > 0 &&
|
||
s.currency != null && (
|
||
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
|
||
<div className="text-term-muted">{t("booth.charges")}</div>
|
||
{(s.chargeLines ?? []).map((c, i) => (
|
||
<div key={i} className="flex justify-between text-term-text">
|
||
<span>{c.label}</span>
|
||
<span className="tabular-nums">+{formatMoney(c.amountMinor, s.currency!)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
|
||
out-of-window window charge; then show that amount. 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-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||
{subWindowDue
|
||
? t("pay.windowCharge")
|
||
: isSubscription
|
||
? t("pay.plan")
|
||
: isOverstay
|
||
? t("pay.topUp")
|
||
: alreadyPaid && s.paidMinor != null
|
||
? // Settled session — the figure is the sum collected, not a quote.
|
||
t("pay.paidAmount")
|
||
: t("pay.total")}
|
||
</span>
|
||
<span className="text-3xl font-bold text-term-cyan">
|
||
{subWindowDue && s.amountMinor != null && s.currency
|
||
? formatMoney(s.amountMinor, s.currency)
|
||
: isSubscription
|
||
? t("pay.prepaid")
|
||
: s.amountMinor != null && s.currency
|
||
? formatMoney(s.amountMinor, s.currency)
|
||
: alreadyPaid && s.paidMinor != null && s.paidCurrency
|
||
? // Settled (within-grace / closed): show the sum actually collected.
|
||
formatMoney(s.paidMinor, s.paidCurrency)
|
||
: alreadyPaid
|
||
? t("booth.badgePaid")
|
||
: t("pay.noTariff")}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Subscription guidance: an unpaid window charge explains the pay-first
|
||
gate; once paid, prompt the operator to open the barrier; a prepaid
|
||
subscriber sees the assist explanation only after revealing it. */}
|
||
{subWindowDue && !windowPaid ? (
|
||
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||
{t("pay.windowChargeHint")}
|
||
</div>
|
||
) : isSubscription && windowPaid ? (
|
||
<div className="rounded-term border border-term-green/40 bg-term-green/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||
{t("pay.windowPaidHint")}
|
||
</div>
|
||
) : isSubscription && assistRevealed ? (
|
||
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||
{t("pay.subAssistHint")}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* 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-[0.75rem] text-term-text">
|
||
{t("pay.overstayHint")}
|
||
</div>
|
||
)}
|
||
|
||
{/* Closed-within-grace: the exit is already paid + recorded; the barrier
|
||
just didn't confirm. Explain that the only action is a manual re-pulse. */}
|
||
{closedWithinGrace && (
|
||
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||
{t("pay.closedWithinGraceHint")}
|
||
</div>
|
||
)}
|
||
|
||
{/* Snapshots */}
|
||
<SnapshotStrip identity={identity} />
|
||
|
||
{/* Tender — shown for any payable case (transient, overstay, OR a
|
||
subscriber window charge that's still unpaid). Card is hidden until a
|
||
P2PE POS terminal is on-site (CARD_PAYMENTS_ENABLED) — see
|
||
lib/features.ts + wiki/concepts/card-payments.md. */}
|
||
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && CARD_PAYMENTS_ENABLED && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
|
||
{(["cash", "card"] as const).map((tn) => (
|
||
<button
|
||
key={tn}
|
||
type="button"
|
||
onClick={() => setTender(tn)}
|
||
className={tender === tn ? "btn btn-primary btn-sm" : "btn btn-sm"}
|
||
>
|
||
{t(`pay.${tn}`)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). Not
|
||
for a closed-within-grace session — its exit is already recorded. */}
|
||
{phase !== "done" && !isSubscription && !closedWithinGrace && (
|
||
<label className="flex items-center gap-2 text-[0.75rem]">
|
||
<input
|
||
type="checkbox"
|
||
className="accent-term-amber"
|
||
checked={voucher}
|
||
onChange={(e) => setPrintVoucherChecked(e.target.checked)}
|
||
/>
|
||
{t("pay.printExitVoucher")}
|
||
<span className="text-term-muted">{t("pay.selfExitHint")}</span>
|
||
</label>
|
||
)}
|
||
|
||
{/* Cancel-ticket reason prompt (revealed by the "Cancel ticket" button).
|
||
A few presets + free text; a reason is REQUIRED. Voiding appends a
|
||
signed `void` event — the entry is never edited. */}
|
||
{voiding && phase !== "done" && (
|
||
<div className="rounded-term border border-term-amber/50 bg-term-amber/5 px-3 py-2">
|
||
<div className="text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
|
||
{t("pay.cancelTicketTitle")}
|
||
</div>
|
||
<div className="mt-1 text-[0.75rem] text-term-text">{t("pay.cancelTicketHint")}</div>
|
||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||
{(["misprint", "test", "wrongVehicle"] as const).map((k) => (
|
||
<button
|
||
key={k}
|
||
type="button"
|
||
onClick={() => setVoidReason(t(`pay.cancelReason.${k}`))}
|
||
className={voidReason === t(`pay.cancelReason.${k}`) ? "btn btn-primary btn-sm" : "btn btn-sm"}
|
||
>
|
||
{t(`pay.cancelReason.${k}`)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<input
|
||
className="input mt-2 w-full"
|
||
value={voidReason}
|
||
onChange={(e) => setVoidReason(e.target.value)}
|
||
placeholder={t("pay.cancelReasonPlaceholder")}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* PLATE-SWAP warning: the exiting plate is already inside under another
|
||
ticket. A prominent, deliberate hold — the operator must consciously
|
||
override to release. See wiki/concepts/plate-reconciliation.md. */}
|
||
{swap && (
|
||
<div className="rounded-term border border-term-red bg-term-red/10 px-3 py-2">
|
||
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-red">
|
||
{t("pay.swapTitle")}
|
||
</div>
|
||
<div className="mt-1 text-[0.75rem] text-term-text">
|
||
{t("pay.swapBody", {
|
||
plate: swap.plate,
|
||
other: swap.otherIdentity,
|
||
when: swap.otherEnteredAt ? formatRelativeDateTime(swap.otherEnteredAt, t) : "—",
|
||
})}
|
||
</div>
|
||
<div className="mt-1 text-[0.6875rem] text-term-muted">{t("pay.swapHint")}</div>
|
||
</div>
|
||
)}
|
||
|
||
{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" ? (
|
||
<>
|
||
{/* Reprint the payment receipt (slip jammed / customer asks).
|
||
Only for a charged session — a subscription has no payment. */}
|
||
{!isSubscription && (
|
||
<button
|
||
type="button"
|
||
onClick={handleReprintReceipt}
|
||
disabled={reprinting}
|
||
className="btn btn-sm"
|
||
>
|
||
{reprinting ? t("pay.reprinting") : t("pay.reprintReceipt")}
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="btn btn-primary btn-sm"
|
||
>
|
||
{t("common.close")}
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="btn btn-ghost btn-sm"
|
||
>
|
||
{t("common.cancel")}
|
||
</button>
|
||
{closedWithinGrace ? (
|
||
// Paid + exited but the barrier didn't confirm — the only action is
|
||
// an audited manual re-pulse (the server re-opens without signing a
|
||
// second exit). No payment, no voucher; mirrors reopenBarrier's guard.
|
||
<button
|
||
type="button"
|
||
onClick={handleOpenBarrier}
|
||
disabled={!shiftReady || phase === "finishing"}
|
||
className="btn btn-pay btn-lg"
|
||
>
|
||
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
||
</button>
|
||
) : isSubscription ? (
|
||
subWindowDue && !windowPaid ? (
|
||
// Step 1 — a window charge is owed: take payment first. The
|
||
// barrier open is the explicit next step (revealed once paid).
|
||
<button
|
||
type="button"
|
||
onClick={handlePaySubscriptionWindow}
|
||
disabled={!shiftReady || phase === "paying"}
|
||
className="btn btn-go btn-lg"
|
||
>
|
||
{phase === "paying" ? t("pay.takingPayment") : t("pay.payWindowCharge")}
|
||
</button>
|
||
) : windowPaid || assistRevealed ? (
|
||
// The audited barrier open. Shown only AFTER a window charge is
|
||
// settled, or after the operator explicitly reveals the assist —
|
||
// never as the default action for a prepaid subscriber.
|
||
<button
|
||
type="button"
|
||
onClick={handleOpenBarrier}
|
||
disabled={!shiftReady || phase === "finishing"}
|
||
className="btn btn-pay btn-lg"
|
||
>
|
||
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
||
</button>
|
||
) : (
|
||
// Prepaid, nothing owed: no default open. A small reveal exposes
|
||
// the audited manual open for a faulty reader / lost card.
|
||
<button
|
||
type="button"
|
||
onClick={() => setAssistRevealed(true)}
|
||
disabled={!shiftReady}
|
||
className="btn btn-ghost btn-sm"
|
||
>
|
||
{t("pay.assistOpenReveal")}
|
||
</button>
|
||
)
|
||
) : voiding ? (
|
||
// Cancel-ticket confirm (reason prompt is shown above).
|
||
<button
|
||
type="button"
|
||
onClick={handleVoidTicket}
|
||
disabled={!voidReason.trim() || phase === "finishing"}
|
||
className="btn btn-danger btn-lg"
|
||
>
|
||
{phase === "finishing" ? t("pay.cancelling") : t("pay.confirmCancelTicket")}
|
||
</button>
|
||
) : (
|
||
<>
|
||
{/* Cancel a wrongly-printed ticket (transient, unpaid, open only;
|
||
gated on event:void). Reveals the reason prompt above. */}
|
||
{canCancel && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setVoiding(true)}
|
||
className="btn btn-ghost btn-sm text-term-red"
|
||
>
|
||
{t("pay.cancelTicket")}
|
||
</button>
|
||
)}
|
||
{swap ? (
|
||
// Plate-swap held → the only forward action is a conscious
|
||
// override (re-submit with override:true; payment already taken).
|
||
<button
|
||
type="button"
|
||
onClick={() => handlePayAndExit(true)}
|
||
disabled={!shiftReady || phase === "finishing"}
|
||
className="btn btn-danger btn-lg"
|
||
>
|
||
{phase === "finishing" ? t("pay.opening") : t("pay.swapOverride")}
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => handlePayAndExit()}
|
||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
||
className="btn btn-go btn-lg"
|
||
>
|
||
{phase === "paying"
|
||
? t("pay.takingPayment")
|
||
: phase === "finishing"
|
||
? voucher
|
||
? t("pay.printingVoucher")
|
||
: t("pay.opening")
|
||
: alreadyPaid
|
||
? voucher
|
||
? t("pay.printVoucher")
|
||
: t("pay.openBarrier")
|
||
: voucher
|
||
? t("pay.payAndVoucher")
|
||
: t("pay.payAndOpen")}
|
||
</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-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</span>
|
||
<span className={`text-sm ${valueClass}`}>{value}</span>
|
||
</div>
|
||
);
|
||
}
|