692dff5f89
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
253 lines
9.4 KiB
TypeScript
253 lines
9.4 KiB
TypeScript
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>
|
||
);
|
||
}
|