Files
parking_solution/apps/web/src/ValidateScreen.tsx
T
julian 692dff5f89 feat(validations): merchant (bar/lavazh) ticket validations end-to-end
In-park merchants discharge customers' parking: a merchant user scans the
ticket on their device (/validate; validation:create + program↔user binding)
and applies their program — comp / first-N-minutes free / amount-off (capped,
typed at scan) / percent. All money stays at the booth: the quote folds live
validations in a canonical order (timeCredit → percent → fixed → comp, net
floors at 0, Σ lines ≡ gross − net), the payment records gross/discount and
CONSUMES the validation ids (an overstay's fresh period never re-applies
them), the receipt prints the gross → lines → net story, and the Z/X-report
carries discountTotalMinor leakage. Every apply/void is a signed, attributed
ledger event (refId = append-only void); program config is /setup/site master
data (Bar/Lavazh checkboxes + right-column panel, tabs when both) whose saves
sign config_change. Migration 0024 + reset-db drift-guard entries; 8 route
integration tests + priceSession fold suite.

See wiki/concepts/validation-discounts.md for the full design record.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
2026-07-13 19:49:58 +02:00

253 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
applyValidation,
fetchMyValidationPrograms,
fetchValidationSession,
voidValidation,
type SessionUser,
type ValidationProgramView,
type ValidationSessionView,
} from "./api.js";
import { formatDuration, formatMoney, formatRelativeDateTime } from "./lib/format.js";
// The MERCHANT screen (/validate): the bar/lavazh user's ENTIRE surface. Scan or key
// the customer's ticket → see the session (deliberately NO money data — the booth
// settles) → apply the bound program → done. Mobile-friendly: a phone/tablet on the
// site LAN, or a booth-style USB HID scanner (it types digits + Enter into the
// focused input). A mistake can be voided while UNUSED (append-only, signed).
// Gated by validation:create + the server-side program↔user binding.
// See wiki/concepts/validation-discounts.md.
type Program = Omit<ValidationProgramView, "userIds">;
/** Human line for what a program grants (the params live on the program row). */
function programSummary(p: Program, t: (k: string, o?: Record<string, unknown>) => string): string {
if (p.mode === "comp") return t("val.modeComp");
if (p.mode === "timeCredit") return `${t("val.modeTimeCredit")}: ${p.minutes ?? 0} min`;
if (p.mode === "percent") return `${t("val.modePercent")}: ${p.percent ?? 0}%`;
return t("val.modeFixed");
}
export function ValidateScreen({ user }: { user: SessionUser }) {
const { t } = useTranslation();
const [programs, setPrograms] = useState<Program[] | null>(null);
const [programId, setProgramId] = useState<string | null>(null);
const [ticket, setTicket] = useState("");
const [view, setView] = useState<ValidationSessionView | null>(null);
const [amount, setAmount] = useState("");
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetchMyValidationPrograms()
.then((r) => {
setPrograms(r.programs);
if (r.programs.length === 1) setProgramId(r.programs[0]!.id);
})
.catch(() => setPrograms([]));
inputRef.current?.focus();
}, []);
const program = programs?.find((p) => p.id === programId) ?? null;
async function lookup(id?: string) {
const identity = (id ?? ticket).trim();
if (!identity) return;
setMsg(null);
try {
setView(await fetchValidationSession(identity));
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
}
}
async function apply() {
if (!view || !program) return;
setBusy(true);
setMsg(null);
try {
const body: { identity: string; programId: string; amountMinor?: number } = {
identity: view.identity,
programId: program.id,
};
if (program.mode === "fixed") {
const n = Number(amount);
body.amountMinor = Number.isFinite(n) ? Math.round(n * 100) : 0;
}
await applyValidation(body);
setMsg({ kind: "ok", text: t("val.applied") });
setAmount("");
await lookup(view.identity);
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
} finally {
setBusy(false);
}
}
async function voidOne(eventId: string) {
if (!view) return;
if (!window.confirm(t("val.confirmVoid"))) return;
setMsg(null);
try {
await voidValidation({ eventId, identity: view.identity });
await lookup(view.identity);
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
}
}
// The session's blocking condition, if any (not found / closed / subscriber).
const blocked =
view == null
? null
: !view.found
? t("val.notFound")
: view.subscription
? t("val.subscription")
: !view.open
? t("val.closed")
: null;
const alreadyApplied =
view != null &&
program != null &&
view.validations.some((v) => v.programId === program.id && !v.voided && v.consumedBy == null);
const fixedAmountOk =
program?.mode !== "fixed" ||
(Number(amount) > 0 &&
(program.maxAmountMinor == null || Math.round(Number(amount) * 100) <= program.maxAmountMinor));
return (
<div className="mx-auto mt-6 w-full max-w-md">
<section className="card p-4">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("val.title")}</div>
{programs != null && programs.length === 0 && (
<p className="mt-3 text-[0.8125rem] text-term-red">{t("val.noPrograms")}</p>
)}
{programs != null && programs.length > 1 && (
<div className="mt-3 flex gap-1">
{programs.map((p) => (
<button
key={p.id}
type="button"
className={`btn btn-sm ${p.id === programId ? "btn-primary" : "btn-ghost"}`}
onClick={() => setProgramId(p.id)}
>
{p.name}
</button>
))}
</div>
)}
{program && <p className="mt-1 text-[0.75rem] text-term-muted">{program.name} — {programSummary(program, t)}</p>}
<form
className="mt-3 flex gap-2"
onSubmit={(e) => {
e.preventDefault();
void lookup();
}}
>
<input
ref={inputRef}
className="input flex-1 tabular-nums"
inputMode="numeric"
value={ticket}
onChange={(e) => setTicket(e.target.value)}
placeholder={t("val.scanPrompt")}
/>
<button type="submit" className="btn btn-primary btn-sm">{t("val.lookup")}</button>
</form>
{msg && (
<p className={`mt-2 text-[0.8125rem] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>
{msg.text}
</p>
)}
{view && (
<div className="mt-3 border-t border-term-border pt-3">
{blocked ? (
<p className="text-[0.8125rem] text-term-red">{blocked}</p>
) : (
<>
<div className="flex items-baseline justify-between text-[0.8125rem]">
<span className="font-semibold tabular-nums text-term-text">{view.identity}</span>
<span className="text-term-muted">
{t("val.entry")} {formatRelativeDateTime(view.enteredAt, t)}
{view.enteredAt && <> · {formatDuration(view.enteredAt, new Date().toISOString())}</>}
</span>
</div>
{program && !alreadyApplied && (
<div className="mt-3 grid gap-2">
{program.mode === "fixed" && (
<div className="field">
<span className="label">
{t("val.amountLabel")}
{program.maxAmountMinor != null && (
<span className="hint ml-2">
{t("val.amountHint", { max: formatMoney(program.maxAmountMinor, "") })}
</span>
)}
</span>
<input
className="input w-40 tabular-nums"
inputMode="decimal"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="300"
/>
</div>
)}
<button
type="button"
className="btn btn-primary"
disabled={busy || !fixedAmountOk}
onClick={apply}
>
{t("val.apply")}
</button>
</div>
)}
{view.validations.length > 0 && (
<div className="mt-3">
<div className="label">{t("val.existing")}</div>
<ul className="mt-1 grid gap-1">
{view.validations.map((v) => (
<li key={v.eventId} className="flex items-center gap-2 text-[0.75rem] text-term-text">
<span>{v.label}</span>
{v.amountMinor != null && <span className="tabular-nums">−{formatMoney(v.amountMinor, "")}</span>}
{v.minutes != null && <span>{v.minutes} min</span>}
{v.percent != null && <span>{v.percent}%</span>}
{v.voided ? (
<span className="text-term-muted">({t("val.voided")})</span>
) : v.consumedBy != null ? (
<span className="text-term-muted">({t("val.used")})</span>
) : (
v.operator === user.username && (
<button type="button" className="btn btn-ghost btn-sm ml-auto" onClick={() => voidOne(v.eventId)}>
{t("val.void")}
</button>
)
)}
</li>
))}
</ul>
</div>
)}
</>
)}
</div>
)}
</section>
</div>
);
}