d71ba82999
After a completed payment the customer always gets a transparency record:
entry time, payment time, duration parked, amount + tender. One shared
ESC/POS renderer (renderReceipt + ReceiptData in @parking/devices), two
modes: VOUCHER = those figures PLUS the scannable Code128 barcode and an
emphasised walk-back-grace line, so the one slip both proves payment and
self-exits at a distant exit reader (replaced the old barcode-only voucher);
STANDALONE = detail-only, auto-printed at payment when no voucher is issued.
Figures fold from the SIGNED ledger (latest payment event); printed on the
booth printer (failover to dispenser). Best-effort: a printer fault never
blocks the exit that already happened — the modal shows a note and offers
"Reprint receipt".
Server: booth-print.ts printPaymentReceipt() + receiptFigures(); routes
POST /api/voucher (voucher) + new POST /api/receipt (standalone/reprint).
Both ESC/POS drivers gained printReceipt(). Web: BoothPayModal auto-prints
after a non-voucher payment + reprint button; api.ts printReceipt().
CP852 fixes found on a real printout: (1) uppercase Ë was mapped to 0xEB
(that's ű) — correct byte is 0xD3; (2) Intl.NumberFormat injects a NO-BREAK
SPACE (U+00A0/U+202F) that isn't in CP852 and printed as "?" — line() now
normalises it to a plain space ("1000 Lekë"); (3) grace line wrapped
mid-word — split into two short lines.
Full build green; both receipt modes render-verified; routes live.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
382 lines
17 KiB
TypeScript
382 lines
17 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 } 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);
|
|
|
|
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 (
|
|
<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="mt-2 rounded-term border border-term-green bg-term-green/10 px-3 py-1 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
|
|
>
|
|
{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={formatTime(s.enteredAt)} />
|
|
<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") : alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
|
|
valueClass={isSubscription ? "text-term-cyan" : alreadyPaid ? "text-term-green" : "text-term-amber"}
|
|
/>
|
|
</div>
|
|
|
|
{/* Total — a subscription is prepaid (no amount); show a badge. */}
|
|
<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")}
|
|
</span>
|
|
<span className="text-3xl font-bold text-term-cyan">
|
|
{isSubscription
|
|
? t("pay.prepaid")
|
|
: s.amountMinor != null && s.currency
|
|
? formatMoney(s.amountMinor, s.currency)
|
|
: alreadyPaid
|
|
? t("booth.badgePaid")
|
|
: t("pay.noTariff")}
|
|
</span>
|
|
</div>
|
|
|
|
{/* For a subscription, explain the only available action. */}
|
|
{isSubscription && (
|
|
<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>
|
|
)}
|
|
|
|
{/* Snapshots */}
|
|
<SnapshotStrip identity={identity} />
|
|
|
|
{phase !== "done" && !isSubscription && (
|
|
<>
|
|
{/* Tender */}
|
|
{canPay && (
|
|
<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={`rounded-term border px-3 py-1 text-[12px] uppercase tracking-wider ${
|
|
tender === tn
|
|
? "border-term-amber text-term-amber"
|
|
: "border-term-border text-term-muted hover:text-term-text"
|
|
}`}
|
|
>
|
|
{t(`pay.${tn}`)}
|
|
</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)}
|
|
/>
|
|
{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="rounded-term border border-term-border px-3 py-1.5 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text disabled:opacity-50"
|
|
>
|
|
{reprinting ? t("pay.reprinting") : t("pay.reprintReceipt")}
|
|
</button>
|
|
)}
|
|
<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"
|
|
>
|
|
{t("common.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"
|
|
>
|
|
{t("common.cancel")}
|
|
</button>
|
|
{isSubscription ? (
|
|
// Prepaid — the only action is the audited barrier open (assist
|
|
// a faulty exit reader / missing card). Gated on an open shift.
|
|
<button
|
|
type="button"
|
|
onClick={handleOpenBarrier}
|
|
disabled={!shiftReady || phase === "finishing"}
|
|
className="rounded-term border border-term-cyan bg-term-cyan/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-cyan disabled:opacity-50"
|
|
>
|
|
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={handlePayAndExit}
|
|
disabled={!shiftReady || 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"
|
|
? 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>
|
|
);
|
|
}
|