import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { ApiError, createTariffDraft, deleteTariffDraft, fetchTariff, fetchTariffDrafts, publishTariffVersion, simulateTariff, updateTariffDraft, type SimulateResult, type TariffDraft, type TariffState, } from "./api.js"; import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js"; import { Modal } from "./ui/Modal.js"; import { formatClock, formatDateTime, formatMoney, formatDuration } from "./lib/format.js"; import type { FeeBreakdown } from "@parking/shared"; import type { TFunction } from "i18next"; // The TARIFF LAB — a sandbox for composing + pricing EXPERIMENTAL rate cards. Drafts // live in their own mutable table (tariff_drafts), so experimenting never churns the // immutable published versions or risks a half-baked card going live: the admin // composes a draft in the modal (the same form the composer page uses), simulates // hypothetical stays against it (entry + exit, nothing else), and only when satisfied // PUBLISHES it through the normal immutable-version path. Pricing uses the SAME // `priceSession` the booth uses (server-side), so the lab and the live booth can // never diverge. No ledger writes. See wiki/concepts/tariff.md. /** wants "YYYY-MM-DDTHH:mm" in LOCAL time. */ function toLocalInput(iso: string): string { const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; const pad = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } /** A local datetime-local value → ISO-8601 (treats the value as local wall-clock). */ function fromLocalInput(v: string): string { const d = new Date(v); return Number.isNaN(d.getTime()) ? "" : d.toISOString(); } function nowLocal(): string { return toLocalInput(new Date().toISOString()); } /** What the simulation runs against: the live card, a historical published * version, or one lab draft. */ type Selection = { kind: "active" } | { kind: "version"; id: string } | { kind: "draft"; id: string }; /** Modal state: a draft being composed (id null = not yet saved). */ interface DraftEdit { id: string | null; name: string; form: FormState; } export function TariffLab() { const { t } = useTranslation(); const [state, setState] = useState(null); const [drafts, setDrafts] = useState([]); const [selected, setSelected] = useState({ kind: "active" }); const [err, setErr] = useState(null); const [notice, setNotice] = useState(null); // The hypothetical stay: entry + exit, nothing else. const [entered, setEntered] = useState(() => { const d = new Date(); d.setHours(d.getHours() - 3); // default: a 3h-ago entry return toLocalInput(d.toISOString()); }); const [exit, setExit] = useState(nowLocal); const [result, setResult] = useState(null); const [busy, setBusy] = useState(false); // The draft-composer modal. const [edit, setEdit] = useState(null); const [saving, setSaving] = useState(false); const [editErr, setEditErr] = useState(null); async function refresh() { const [s, d] = await Promise.all([fetchTariff(), fetchTariffDrafts()]); setState(s); setDrafts(d.drafts); return d.drafts; } useEffect(() => { refresh().catch((e) => setErr((e as Error).message)); }, []); const selectedDraft = selected.kind === "draft" ? drafts.find((d) => d.id === selected.id) ?? null : null; const selectedVersion = selected.kind === "version" ? state?.versions.find((v) => v.id === selected.id) ?? null : null; function select(sel: Selection) { setSelected(sel); setResult(null); // a stale price against another card would mislead setErr(null); setNotice(null); } async function run() { setErr(null); setBusy(true); try { const r = await simulateTariff({ enteredAt: fromLocalInput(entered), asOf: fromLocalInput(exit), // A draft carries its own structure+currency; a historical version is // referenced by id; otherwise the ACTIVE version. ...(selectedDraft ? { structure: selectedDraft.structure, currency: selectedDraft.currency } : selectedVersion ? { tariffVersionId: selectedVersion.id } : {}), }); setResult(r); } catch (e) { setErr((e as Error).message); setResult(null); } finally { setBusy(false); } } // --- draft actions --- function newDraft() { // Start from the live card when there is one — the admin usually experiments // with a variation of today's prices, not from a blank slate. const form = state?.active ? formFromActive(state) : emptyForm(); setEditErr(null); setEdit({ id: null, name: "", form }); } function editDraft(d: TariffDraft) { setEditErr(null); setEdit({ id: d.id, name: d.name, form: formFromVersion(d.currency, d.structure) }); } async function saveDraft() { if (!edit) return; setSaving(true); setEditErr(null); try { const body = { name: edit.name.trim(), currency: edit.form.currency.trim().toUpperCase(), structure: toStructure(edit.form), }; const saved = edit.id ? await updateTariffDraft(edit.id, body) : await createTariffDraft(body); await refresh(); setEdit(null); select({ kind: "draft", id: saved.id }); } catch (e) { const text = e instanceof ApiError && e.problems?.length ? `${e.message}: ${e.problems.join("; ")}` : (e as Error).message; setEditErr(text); } finally { setSaving(false); } } async function removeDraft(d: TariffDraft) { if (!confirm(t("lab.confirmDelete", { name: d.name }))) return; setErr(null); try { await deleteTariffDraft(d.id); await refresh(); select({ kind: "active" }); } catch (e) { setErr((e as Error).message); } } async function publishDraft(d: TariffDraft) { if (!confirm(t("lab.confirmPublish", { name: d.name }))) return; setErr(null); setNotice(null); try { // The draft's name rides along onto the immutable version. await publishTariffVersion({ currency: d.currency, structure: d.structure, name: d.name }); await refresh(); setNotice(t("tariff.publishedOk")); } catch (e) { const text = e instanceof ApiError && e.problems?.length ? `${e.message}: ${e.problems.join("; ")}` : (e as Error).message; setErr(text); } } const currency = result?.currency ?? selectedDraft?.currency ?? selectedVersion?.currency ?? state?.active?.currency ?? "ALL"; return (

{t("lab.title")}

{t("lab.intro")}

{/* Main: the hypothetical stay + result, priced against the selection. */}
{/* What we're pricing against + draft actions. */}
{selectedDraft ? selectedDraft.name : selectedVersion ? selectedVersion.name ?? formatDateTime(selectedVersion.effectiveFrom, t) : t("lab.activeTariff")} {selectedDraft && ( <> )} {notice && {notice}}
setEntered(e.target.value)} /> setExit(e.target.value)} />
{err && {err}}
{result && (
{/* Outcome */}

{t("lab.outcome")}

{t("lab.amountDue")}
{formatMoney(result.pricing.amountMinor, currency)}
{t("lab.billedPeriod")}
{formatDuration(result.pricing.periodStart, fromLocalInput(exit))} {result.pricing.overstay && ( {t("lab.overstay")} )} {result.pricing.withinGrace && ( {t("lab.settled")} )}
{t("lab.periodStart")}
{formatDateTime(result.pricing.periodStart, t)}
{result.pricing.graceExpiresAt && ( <>
{t("lab.graceExpires")}
{formatDateTime(result.pricing.graceExpiresAt, t)}
)}
{/* HOW the sum is produced — line items from the SAME engine walk (their sum is the amount by construction). */} {result.breakdown && ( )}
{/* Duration curve from entry — see where the cap flattens / windows shift. */}

{t("lab.curve")}

{t("lab.curveHint")}

{result.curve.map((c) => ( ))}
{labelMin(c.minutes)} {formatMoney(c.amountMinor, currency)}
)}
{/* Sidebar: lab drafts + the full published history; click any to price against it. */}
{/* The draft composer — the SAME form the /setup/tariff page uses, in a modal. */} setEdit(null)} title={edit?.id ? t("lab.editDraftTitle") : t("lab.newDraftTitle")} width="max-w-3xl" > {edit && (
setEdit((d) => (d ? { ...d, name: e.target.value } : d))} placeholder={t("lab.draftNamePh")} />
setEdit((d) => (d ? { ...d, form: update(d.form) } : d))} />
{editErr && {editErr}}
)}
); } function labelMin(min: number): string { if (min < 60) return `${min}m`; if (min < 1440) return `${min / 60}h`; return `${min / 1440}d`; } /** The fee's line items — every row states its time window / rule and its amount, so * the operator can retrace the exact sum (caps show as negative adjustments). */ function BreakdownTable({ b, periodStart, currency, t, }: { b: FeeBreakdown; periodStart: string; currency: string; t: TFunction; }) { const startMs = Date.parse(periodStart); const multiDay = b.billedMinutes > 1440; const at = (min: number) => { const iso = new Date(startMs + min * 60_000).toISOString(); return multiDay ? formatDateTime(iso, t) : formatClock(iso); }; const money = (m: number) => formatMoney(m, currency); const hours = (min: number) => (min % 60 === 0 ? `${min / 60}` : (min / 60).toFixed(1)); return (
{t("lab.bd.title")}
{b.billedMinutes > 0 && (

{t("lab.bd.rounding", { raw: b.rawMinutes, billed: b.billedMinutes, inc: b.incrementMin })}

)} {b.items.map((it, i) => { let label: string; let amount: number; let cls = "text-term-text"; switch (it.kind) { case "grace": label = t("lab.bd.grace", { min: it.minutes }); amount = 0; cls = "text-term-green"; break; case "band": label = `${at(it.fromMin)}–${at(it.toMin)} · ${it.increments} × ${money(it.unitMinor)}${it.card ? ` · ${it.card}` : ""}`; amount = it.amountMinor; break; case "package": label = `${at(it.fromMin)} · ${it.card} — ${t("lab.bd.package")}`; amount = it.amountMinor; break; case "step": label = it.repeated ? t("lab.bd.stepRepeated", { day: it.day }) : t("lab.bd.step", { day: it.day, hours: hours(it.uptoMin) }); amount = it.amountMinor; break; case "cap": label = t("lab.bd.cap", { day: it.day, cap: money(it.capMinor) }); amount = it.amountMinor; cls = "text-term-red"; break; } return ( ); })}
{label} {money(amount)}
{t("lab.bd.total")} {money(b.totalMinor)}
); }