feat(subs): scannable out-of-window slip + two-step booth flow
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
This commit is contained in:
+100
-45
@@ -45,6 +45,13 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
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.
|
||||
@@ -87,6 +94,25 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -280,17 +306,22 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* For a subscription with a window charge, explain why it's payable. For a
|
||||
plain prepaid subscription, explain the assist-open is the only action. */}
|
||||
{subWindowDue ? (
|
||||
{/* 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 && (
|
||||
) : 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 && (
|
||||
@@ -302,37 +333,36 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
{/* 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={tender === tn ? "btn btn-primary btn-sm" : "btn btn-sm"}
|
||||
>
|
||||
{t(`pay.${tn}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 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 (default from site config) */}
|
||||
<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>
|
||||
</>
|
||||
{/* 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>}
|
||||
@@ -374,16 +404,41 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
{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="btn btn-pay btn-lg"
|
||||
>
|
||||
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
||||
</button>
|
||||
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"
|
||||
|
||||
@@ -705,8 +705,11 @@ export const en: Catalog = {
|
||||
plan: "Plan",
|
||||
prepaid: "PREPAID",
|
||||
subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.",
|
||||
assistOpenReveal: "Assist open (faulty reader / lost card)…",
|
||||
payWindowCharge: "Take payment",
|
||||
windowPaidHint: "Window charge paid. Open the barrier to let the subscriber out.",
|
||||
windowCharge: "OUT-OF-WINDOW",
|
||||
windowChargeHint: "This subscriber parked outside their plan's allowed hours. They owe the transient tariff for the out-of-window time — take payment to allow the exit.",
|
||||
windowChargeHint: "This subscriber parked outside their plan's allowed hours. They owe the transient tariff for the out-of-window time — take payment, then open the barrier.",
|
||||
subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).",
|
||||
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
|
||||
// payment receipt (transparency slip)
|
||||
|
||||
@@ -719,8 +719,11 @@ export const sq = {
|
||||
plan: "Plani",
|
||||
prepaid: "I PARAPAGUAR",
|
||||
subAssistHint: "Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.",
|
||||
assistOpenReveal: "Ndihmë për hapje (lexues me defekt / kartë e humbur)…",
|
||||
payWindowCharge: "Merr pagesën",
|
||||
windowPaidHint: "Pagesa jashtë orarit u krye. Hap barrierën që abonenti të dalë.",
|
||||
windowCharge: "JASHTË ORARIT",
|
||||
windowChargeHint: "Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën për të lejuar daljen.",
|
||||
windowChargeHint: "Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën, pastaj hap barrierën.",
|
||||
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
|
||||
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
|
||||
// payment receipt (transparency slip)
|
||||
|
||||
Reference in New Issue
Block a user