df5caf8d87
The advisory out-of-window slip for a subscriber had two problems:
1. Faulty character codes. It rendered via the generic text printReport,
which has no CP852 mapping for the em dash, ellipsis, or warning sign in
the composed strings — so they printed as "?" ("PARKIM ? JASHTE ORARIT").
Added ASCII transliterations for that typographic punctuation in the
ESC/POS encoder (— → -, ⚠ → !, … → ..., curly quotes/bullet), so they
degrade to a readable glyph instead of "?".
2. Not scannable. The slip printed only "Nr: SUBSESS-…" as plain text, so
the operator had to hand-key it. Gave the notice its own render function
(renderWindowChargeNotice) + a printWindowChargeNotice device method that
prints the occurrence id as a Code128 AND a QR — the same scan path as a
transient ticket, so the operator scans it straight into the booth pay
modal, which then quotes the combined window charge. Implemented on both
the rongta and cashino drivers.
Also fixed the booth pay modal: "Open barrier" no longer shows by default
for a subscriber. A prepaid subscriber with nothing owed sees only a small
"assist open" reveal (the audited manual open for a faulty reader / lost
card stays available, just not the default). A subscriber owing an
out-of-window charge is now two steps — take payment first, then "Open
barrier" appears — instead of an always-on open button.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
484 lines
22 KiB
TypeScript
484 lines
22 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,
|
|
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, formatRelativeDateTime } 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<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);
|
|
|
|
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;
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
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. 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);
|
|
}
|
|
// 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 (
|
|
<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">
|
|
{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-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
|
{t("shift.gateOtherTitle")}
|
|
</div>
|
|
<div className="mt-1 text-[12px] text-term-text">
|
|
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
|
{t("shift.gateTitle")}
|
|
</div>
|
|
<div className="mt-1 text-[12px] text-term-text">{t("shift.gateBody")}</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleOpenShift}
|
|
disabled={openingShift}
|
|
className="btn btn-go btn-sm mt-2"
|
|
>
|
|
{openingShift ? t("shift.opening") : 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 && (
|
|
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
|
{t("pay.alreadyClosed", { time: 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={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
|
|
<Row label={t("pay.now")} value={formatTime(new Date().toISOString())} />
|
|
<Row
|
|
label={t("pay.duration")}
|
|
value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"}
|
|
/>
|
|
<Row
|
|
label={t("pay.statusLabel")}
|
|
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) 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-[11px] uppercase tracking-wider text-term-muted">
|
|
{subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : 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
|
|
? 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-[12px] 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-[12px] 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-[12px] 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-[12px] text-term-text">
|
|
{t("pay.overstayHint")}
|
|
</div>
|
|
)}
|
|
|
|
{/* Snapshots */}
|
|
<SnapshotStrip identity={identity} />
|
|
|
|
{/* Tender — shown for any payable case (transient, overstay, OR a
|
|
subscriber window charge that's still unpaid). */}
|
|
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[11px] 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). */}
|
|
{phase !== "done" && !isSubscription && (
|
|
<label className="flex items-center gap-2 text-[12px]">
|
|
<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>
|
|
)}
|
|
|
|
{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>
|
|
{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>
|
|
)
|
|
) : (
|
|
<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-[11px] uppercase tracking-wider text-term-muted">{label}</span>
|
|
<span className={`text-sm ${valueClass}`}>{value}</span>
|
|
</div>
|
|
);
|
|
}
|