feat(booth): cancel wrongly-printed ticket (signed void) + refused-vs-anomaly display; fix CI uv
CI / check (push) Failing after 56s
CI / check (push) Failing after 56s
Cancel a misprinted/test/wrong-vehicle ticket via a SIGNED `void` event — the
vehicle_entry is never edited/deleted (append-only). VoidFlow appends void{
voidedEntryRef, voidReason, operator, reasonCode:"void.ticketCancelled" }; route
POST /api/tickets/void gated event:void + open shift; reason REQUIRED. Refuses a
subscription / already-exited / already-voided / paid ticket (refund out of scope).
The void folds the session CLOSED everywhere it's counted — occupancy (count +
reserved spots), pay-station (lookup/activeSessions), exit-flow (#sessionFor), and
reports (excluded from entries) — so a voided car stops occupying a spot, can't be
paid/exited, and doesn't inflate "cars entered". No barrier action. Booth UI: a
"Cancel ticket" action in the pay/exit lookup modal (transient + unpaid + open;
gated on event:void) with a preset-or-free reason prompt.
Reclassify the Live feed: refused-action events (exitRefused/entryRefused/
permitRefused — e.g. a double card-scan, at-capacity subscriber, exit on a closed
session) are benign warnings, not red anomalies. event-detail.tsx now shows them as
amber REFUZUAR/REFUSED, reserving red ANOMALI for genuine red-flags. Display-only —
no ledger change, so historical events reclassify too.
CI: install uv + sync vision deps before the Turbo run. @parking/vision's lint/
typecheck/test shell to `uv run …`, but CI set up only Node+pnpm, so `uv run ruff`
failed ("uv not found") and broke the whole Turbo run. The Python checks pass once
uv provisions the toolchain.
- new: void-flow.ts (+ tests, 8) ; occupancy void-fold test
- shared: reason code void.ticketCancelled ; both web catalogs (sq/en parity)
- wiki: parking-session (ticket-void folds + guards, refused/anomaly split), log
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
+101
-17
@@ -4,6 +4,7 @@ import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
boothExit,
|
||||
can,
|
||||
fetchSiteConfig,
|
||||
lookupSession,
|
||||
openShift,
|
||||
@@ -11,8 +12,10 @@ import {
|
||||
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, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
||||
@@ -52,6 +55,11 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
// 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
|
||||
const [voidReason, setVoidReason] = useState("");
|
||||
|
||||
const s: SessionLookup | undefined = session.data;
|
||||
// Checkbox default comes from config the first time it loads; operator can toggle.
|
||||
@@ -127,6 +135,29 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -365,6 +396,36 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
</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-[11px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{t("pay.cancelTicketTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] 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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
@@ -439,27 +500,50 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
{t("pay.assistOpenReveal")}
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
) : voiding ? (
|
||||
// Cancel-ticket confirm (reason prompt is shown above).
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePayAndExit}
|
||||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
||||
className="btn btn-go btn-lg"
|
||||
onClick={handleVoidTicket}
|
||||
disabled={!voidReason.trim() || phase === "finishing"}
|
||||
className="btn btn-danger 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")}
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user