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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1085,6 +1085,13 @@ export function paySession(
|
||||
});
|
||||
}
|
||||
|
||||
/** Cancel (void) a wrongly-printed transient ticket. Appends a SIGNED `void` event with
|
||||
* the operator + a required reason; the entry itself is never edited (append-only).
|
||||
* Refuses a subscription / already-exited / already-voided / paid ticket (409). */
|
||||
export function voidTicket(identity: string, reason: string): Promise<{ ok: boolean; identity?: string }> {
|
||||
return apiFetch("/api/tickets/void", { method: "POST", body: JSON.stringify({ identity, reason }) });
|
||||
}
|
||||
|
||||
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
|
||||
* open (payment stands; operator opens manually). */
|
||||
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
|
||||
|
||||
@@ -156,6 +156,7 @@ export const en: Catalog = {
|
||||
evtCashIn: "PAY-IN",
|
||||
evtCashOut: "PAY-OUT",
|
||||
evtAnomaly: "ANOMALY",
|
||||
evtRefused: "REFUSED",
|
||||
// live-feed event detail line + classification badges (computed from payload)
|
||||
evtNoReason: "no reason recorded",
|
||||
badgeEntryRefused: "entry refused",
|
||||
@@ -224,6 +225,7 @@ export const en: Catalog = {
|
||||
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
|
||||
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
|
||||
"sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth",
|
||||
"void.ticketCancelled": "Ticket cancelled — {{reason}}",
|
||||
},
|
||||
tariff: {
|
||||
title: "Tariff",
|
||||
@@ -799,6 +801,18 @@ export const en: Catalog = {
|
||||
receiptReprinted: "Receipt reprinted on {{printer}}.",
|
||||
reprintReceipt: "Reprint receipt",
|
||||
reprinting: "printing…",
|
||||
cancelTicket: "Cancel ticket",
|
||||
cancelTicketTitle: "Cancel this ticket",
|
||||
cancelTicketHint: "Cancels a wrongly-printed ticket. A signed record is kept (operator + reason); the original entry is never deleted.",
|
||||
cancelReason: {
|
||||
misprint: "Misprint",
|
||||
test: "Test",
|
||||
wrongVehicle: "Wrong vehicle",
|
||||
},
|
||||
cancelReasonPlaceholder: "Reason for cancelling (required)…",
|
||||
confirmCancelTicket: "Confirm cancellation",
|
||||
cancelling: "cancelling…",
|
||||
ticketCancelled: "Ticket cancelled.",
|
||||
noSnapshots: "no snapshots",
|
||||
loadingSnapshots: "loading snapshots…",
|
||||
snapEntry: "entry",
|
||||
|
||||
@@ -160,6 +160,7 @@ export const sq = {
|
||||
evtCashIn: "ARKËTIM",
|
||||
evtCashOut: "PAGESË",
|
||||
evtAnomaly: "ANOMALI",
|
||||
evtRefused: "REFUZUAR",
|
||||
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
|
||||
evtNoReason: "pa arsye të regjistruar",
|
||||
badgeEntryRefused: "hyrje e refuzuar",
|
||||
@@ -227,6 +228,7 @@ export const sq = {
|
||||
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
|
||||
"sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)",
|
||||
"sub.refused.unpaidWindow": "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë",
|
||||
"void.ticketCancelled": "Bileta u anulua — {{reason}}",
|
||||
},
|
||||
tariff: {
|
||||
title: "Tarifa",
|
||||
@@ -813,6 +815,18 @@ export const sq = {
|
||||
receiptReprinted: "Fatura u riprintua në {{printer}}.",
|
||||
reprintReceipt: "Riprinto faturën",
|
||||
reprinting: "duke printuar…",
|
||||
cancelTicket: "Anulo biletën",
|
||||
cancelTicketTitle: "Anulo këtë biletë",
|
||||
cancelTicketHint: "Anulon një biletë të printuar gabimisht. Ruhet një gjurmë e nënshkruar (operatori + arsyeja); hyrja origjinale nuk fshihet kurrë.",
|
||||
cancelReason: {
|
||||
misprint: "Printim i gabuar",
|
||||
test: "Test",
|
||||
wrongVehicle: "Automjet i gabuar",
|
||||
},
|
||||
cancelReasonPlaceholder: "Arsyeja e anulimit (e detyrueshme)…",
|
||||
confirmCancelTicket: "Konfirmo anulimin",
|
||||
cancelling: "duke anuluar…",
|
||||
ticketCancelled: "Bileta u anulua.",
|
||||
// snapshots
|
||||
noSnapshots: "asnjë foto",
|
||||
loadingSnapshots: "duke ngarkuar fotot…",
|
||||
|
||||
@@ -45,7 +45,9 @@ export interface RouterContext {
|
||||
setUser: (u: SessionUser | null) => void;
|
||||
}
|
||||
|
||||
const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||||
// Exported so a deep component (e.g. the booth pay modal) can read the signed-in user
|
||||
// from route context without prop-threading through every layer.
|
||||
export const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||||
component: RootLayout,
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,27 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
|
||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||
};
|
||||
|
||||
/**
|
||||
* A refused-ACTION event is a benign WARNING, not a red-flag anomaly. The ledger type is
|
||||
* `anomaly` for both (immutable history), but a refused exit / refused subscription /
|
||||
* refused entry (e.g. a double card-scan, an at-capacity subscriber, an already-closed
|
||||
* session) is an EXPECTED outcome — not fraud. We classify it from the payload flags the
|
||||
* flows already sign (`exitRefused` / `entryRefused` / `permitRefused`) and show it as an
|
||||
* amber "REFUZUAR / REFUSED" warning, reserving red "ANOMALI" for genuine anomalies
|
||||
* (barrier-open failure, opened-without-ticket, …). Display-only — no ledger change.
|
||||
*/
|
||||
export function isRefusedWarning(e: LedgerEvent): boolean {
|
||||
if (e.type !== "anomaly") return false;
|
||||
const p = e.payload;
|
||||
return !!(p && (p.exitRefused || p.entryRefused || p.permitRefused));
|
||||
}
|
||||
|
||||
/** The label key + colour to render for an event, applying the refused-warning split. */
|
||||
export function eventStyleFor(e: LedgerEvent): { labelKey: string; color: string } {
|
||||
if (isRefusedWarning(e)) return { labelKey: "booth.evtRefused", color: "text-term-amber" };
|
||||
return EVENT_STYLE[e.type] ?? { labelKey: "", color: "text-term-text" };
|
||||
}
|
||||
|
||||
/** Local time-of-day, terminal style. Defensive against a bad timestamp. */
|
||||
function hhmmss(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
@@ -79,9 +100,12 @@ export function displayIdentity(e: LedgerEvent): string {
|
||||
* its own row, indented under the identity column. */
|
||||
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const style = EVENT_STYLE[e.type];
|
||||
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
||||
const isAnomaly = e.type === "anomaly";
|
||||
const style = eventStyleFor(e);
|
||||
const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase();
|
||||
// A refused-action event is a benign WARNING (amber), distinct from a genuine red
|
||||
// anomaly. Only true anomalies get the red row tint + the "no reason" fallback.
|
||||
const refusedWarning = isRefusedWarning(e);
|
||||
const isAnomaly = e.type === "anomaly" && !refusedWarning;
|
||||
const p = e.payload;
|
||||
const reason = renderReason(p, t);
|
||||
const amount = paymentSummary(p);
|
||||
@@ -95,11 +119,11 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
|
||||
type="button"
|
||||
onClick={() => onOpen(e)}
|
||||
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
|
||||
isAnomaly ? "bg-term-red/5" : ""
|
||||
isAnomaly ? "bg-term-red/5" : refusedWarning ? "bg-term-amber/5" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
||||
<span className={`shrink-0 font-semibold ${style.color}`}>{label}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
||||
{e.plate && (
|
||||
@@ -152,12 +176,12 @@ function DetailRow({ label, children }: { label: string; children: ReactNode })
|
||||
* this only DISPLAYS the signed record. */
|
||||
export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const style = EVENT_STYLE[e.type];
|
||||
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
||||
const style = eventStyleFor(e);
|
||||
const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase();
|
||||
const p = e.payload;
|
||||
const reason = renderReason(p, t);
|
||||
const badges = eventBadges(p);
|
||||
const isAnomaly = e.type === "anomaly";
|
||||
const isAnomaly = e.type === "anomaly" && !isRefusedWarning(e);
|
||||
|
||||
// Pretty money for any minor-unit amount in the payload.
|
||||
const money =
|
||||
@@ -177,7 +201,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Headline: the type + localized reason, prominent for anomalies. */}
|
||||
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
|
||||
<div className={`text-sm font-bold uppercase tracking-widest ${style?.color ?? "text-term-text"}`}>{label}</div>
|
||||
<div className={`text-sm font-bold uppercase tracking-widest ${style.color}`}>{label}</div>
|
||||
{(reason || money) && (
|
||||
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
||||
{reason ?? money}
|
||||
|
||||
Reference in New Issue
Block a user