7649b897c4
"ALL 740 / 3h 2m" gave no derivation. explainFee in @parking/shared runs the EXACT computeFee walk with an optional trace collector — one code path, so Σ line items ≡ the amount by construction (golden V1 regression byte-identical; instrumentation changes no fee). Items: contiguous same-price increment runs (time window · N × unit · tier-card name), window-package occurrences, stepped day totals (top-tier repeat flagged), daily-cap clamps as NEGATIVE adjustments, entry grace. /api/tariff/simulate returns `breakdown` (null when settled); the lab's Outcome panel renders the lined table with a rounding note (raw min → billed min at the increment — answers "why does 3h 2m bill as 4h") and a total row. Works against active/historical versions and drafts alike, so a night-package draft can be verified line by line before publish. Largely delivers the wiki's open "composer price preview" item. 4 new engine tests pin the sum invariant + item shapes (97 shared green). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
505 lines
21 KiB
TypeScript
505 lines
21 KiB
TypeScript
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.
|
||
|
||
/** <input type="datetime-local"> 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<TariffState | null>(null);
|
||
const [drafts, setDrafts] = useState<TariffDraft[]>([]);
|
||
const [selected, setSelected] = useState<Selection>({ kind: "active" });
|
||
const [err, setErr] = useState<string | null>(null);
|
||
const [notice, setNotice] = useState<string | null>(null);
|
||
|
||
// The hypothetical stay: entry + exit, nothing else.
|
||
const [entered, setEntered] = useState<string>(() => {
|
||
const d = new Date();
|
||
d.setHours(d.getHours() - 3); // default: a 3h-ago entry
|
||
return toLocalInput(d.toISOString());
|
||
});
|
||
const [exit, setExit] = useState<string>(nowLocal);
|
||
|
||
const [result, setResult] = useState<SimulateResult | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
// The draft-composer modal.
|
||
const [edit, setEdit] = useState<DraftEdit | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [editErr, setEditErr] = useState<string | null>(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 (
|
||
<section className="px-4 py-6">
|
||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("lab.title")}</h2>
|
||
<p className="hint mb-4">{t("lab.intro")}</p>
|
||
|
||
<div className="flex flex-col gap-4 lg:flex-row">
|
||
{/* Main: the hypothetical stay + result, priced against the selection. */}
|
||
<div className="min-w-0 flex-1">
|
||
{/* What we're pricing against + draft actions. */}
|
||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||
<span className="rounded bg-term-panel-2 px-2 py-1 text-[0.75rem] text-term-cyan">
|
||
{selectedDraft
|
||
? selectedDraft.name
|
||
: selectedVersion
|
||
? selectedVersion.name ?? formatDateTime(selectedVersion.effectiveFrom, t)
|
||
: t("lab.activeTariff")}
|
||
</span>
|
||
{selectedDraft && (
|
||
<>
|
||
<button type="button" className="btn btn-sm" onClick={() => editDraft(selectedDraft)}>
|
||
{t("lab.edit")}
|
||
</button>
|
||
<button type="button" className="btn btn-sm" onClick={() => publishDraft(selectedDraft)}>
|
||
{t("lab.publish")}
|
||
</button>
|
||
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeDraft(selectedDraft)}>
|
||
{t("lab.delete")}
|
||
</button>
|
||
</>
|
||
)}
|
||
{notice && <span className="text-[0.75rem] text-term-green">{notice}</span>}
|
||
</div>
|
||
|
||
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||
<label className="label">{t("lab.entered")}</label>
|
||
<input type="datetime-local" className="input w-64" value={entered} onChange={(e) => setEntered(e.target.value)} />
|
||
|
||
<label className="label">{t("lab.exit")}</label>
|
||
<span className="flex items-center gap-2">
|
||
<input type="datetime-local" className="input w-64" value={exit} onChange={(e) => setExit(e.target.value)} />
|
||
<button type="button" className="btn btn-sm" onClick={() => setExit(nowLocal())}>
|
||
{t("lab.now")}
|
||
</button>
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-4 flex items-center gap-3">
|
||
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
|
||
{busy ? t("lab.pricing") : t("lab.price")}
|
||
</button>
|
||
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
|
||
</div>
|
||
|
||
{result && (
|
||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||
{/* Outcome */}
|
||
<div className="card card-body">
|
||
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
|
||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[0.8125rem]">
|
||
<dt className="text-term-muted">{t("lab.amountDue")}</dt>
|
||
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
|
||
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
|
||
<dd className="text-term-text">
|
||
{formatDuration(result.pricing.periodStart, fromLocalInput(exit))}
|
||
{result.pricing.overstay && (
|
||
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-red">
|
||
{t("lab.overstay")}
|
||
</span>
|
||
)}
|
||
{result.pricing.withinGrace && (
|
||
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-green">
|
||
{t("lab.settled")}
|
||
</span>
|
||
)}
|
||
</dd>
|
||
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
|
||
<dd className="text-term-text">{formatDateTime(result.pricing.periodStart, t)}</dd>
|
||
{result.pricing.graceExpiresAt && (
|
||
<>
|
||
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
|
||
<dd className="text-term-text">{formatDateTime(result.pricing.graceExpiresAt, t)}</dd>
|
||
</>
|
||
)}
|
||
</dl>
|
||
{/* HOW the sum is produced — line items from the SAME engine walk
|
||
(their sum is the amount by construction). */}
|
||
{result.breakdown && (
|
||
<BreakdownTable b={result.breakdown} periodStart={result.pricing.periodStart} currency={currency} t={t} />
|
||
)}
|
||
</div>
|
||
|
||
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
|
||
<div className="card card-body">
|
||
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
|
||
<p className="hint mb-2">{t("lab.curveHint")}</p>
|
||
<table className="w-full text-[0.75rem] tabular-nums">
|
||
<tbody>
|
||
{result.curve.map((c) => (
|
||
<tr key={c.minutes} className="border-b border-term-border/40">
|
||
<td className="py-0.5 text-term-muted">{labelMin(c.minutes)}</td>
|
||
<td className="py-0.5 text-right text-term-text">{formatMoney(c.amountMinor, currency)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Sidebar: lab drafts + the full published history; click any to price
|
||
against it. */}
|
||
<aside className="w-full shrink-0 lg:w-72">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<h3 className="text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.drafts")}</h3>
|
||
<button type="button" className="btn btn-sm" onClick={newDraft}>
|
||
{t("lab.newDraft")}
|
||
</button>
|
||
</div>
|
||
<ul className="flex flex-col gap-1">
|
||
{drafts.map((d) => (
|
||
<li key={d.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => select({ kind: "draft", id: d.id })}
|
||
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
|
||
selected.kind === "draft" && selected.id === d.id
|
||
? "border-term-amber bg-term-amber/10 text-term-text"
|
||
: "border-term-border text-term-muted hover:text-term-text"
|
||
}`}
|
||
>
|
||
<span className="block font-semibold">{d.name}</span>
|
||
<span className="block text-[0.6875rem] text-term-muted">
|
||
{d.currency} · {formatDateTime(d.updatedAt, t)}
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
{drafts.length === 0 && <li className="hint px-1 py-2">{t("lab.noDrafts")}</li>}
|
||
</ul>
|
||
|
||
{/* Published versions: the active card first, then the immutable history
|
||
(older versions still price past sessions — see wiki/concepts/tariff.md). */}
|
||
<h3 className="mb-2 mt-5 text-h6 font-semibold uppercase tracking-wider text-term-text">
|
||
{t("lab.published")}
|
||
</h3>
|
||
<ul className="flex flex-col gap-1">
|
||
<li>
|
||
<button
|
||
type="button"
|
||
onClick={() => select({ kind: "active" })}
|
||
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
|
||
selected.kind === "active"
|
||
? "border-term-amber bg-term-amber/10 text-term-text"
|
||
: "border-term-border text-term-muted hover:text-term-text"
|
||
}`}
|
||
>
|
||
<span className="block font-semibold">
|
||
{t("lab.activeTariff")}
|
||
{state?.active?.name ? ` — ${state.active.name}` : ""}
|
||
</span>
|
||
<span className="block text-[0.6875rem] text-term-muted">
|
||
{state?.active ? formatDateTime(state.active.effectiveFrom, t) : t("tariff.noRateCard")}
|
||
</span>
|
||
</button>
|
||
</li>
|
||
{state?.versions
|
||
.filter((v) => v.id !== state.active?.id)
|
||
.map((v) => (
|
||
<li key={v.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => select({ kind: "version", id: v.id })}
|
||
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
|
||
selected.kind === "version" && selected.id === v.id
|
||
? "border-term-amber bg-term-amber/10 text-term-text"
|
||
: "border-term-border text-term-muted hover:text-term-text"
|
||
}`}
|
||
>
|
||
<span className="block font-semibold">
|
||
{v.name ?? formatDateTime(v.effectiveFrom, t)}
|
||
</span>
|
||
<span className="block text-[0.6875rem] text-term-muted">
|
||
{v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""}
|
||
{v.currency}
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</aside>
|
||
</div>
|
||
|
||
{/* The draft composer — the SAME form the /setup/tariff page uses, in a modal. */}
|
||
<Modal
|
||
open={edit != null}
|
||
onClose={() => setEdit(null)}
|
||
title={edit?.id ? t("lab.editDraftTitle") : t("lab.newDraftTitle")}
|
||
width="max-w-3xl"
|
||
>
|
||
{edit && (
|
||
<div>
|
||
<div className="mb-4 flex items-center gap-2">
|
||
<label className="label">{t("lab.draftName")}</label>
|
||
<input
|
||
className="input w-72"
|
||
value={edit.name}
|
||
onChange={(e) => setEdit((d) => (d ? { ...d, name: e.target.value } : d))}
|
||
placeholder={t("lab.draftNamePh")}
|
||
/>
|
||
</div>
|
||
<TariffEditorForm
|
||
form={edit.form}
|
||
onChange={(update) => setEdit((d) => (d ? { ...d, form: update(d.form) } : d))}
|
||
/>
|
||
<div className="mt-6 flex items-center gap-3">
|
||
<button type="button" className="btn btn-primary" onClick={saveDraft} disabled={saving || !edit.name.trim()}>
|
||
{saving ? t("lab.savingDraft") : t("lab.saveDraft")}
|
||
</button>
|
||
{editErr && <span className="text-[0.75rem] text-term-red">{editErr}</span>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="mt-3 border-t border-term-border pt-2">
|
||
<div className="mb-1 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("lab.bd.title")}</div>
|
||
{b.billedMinutes > 0 && (
|
||
<p className="hint mb-1.5">
|
||
{t("lab.bd.rounding", { raw: b.rawMinutes, billed: b.billedMinutes, inc: b.incrementMin })}
|
||
</p>
|
||
)}
|
||
<table className="w-full text-[0.75rem] tabular-nums">
|
||
<tbody>
|
||
{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 (
|
||
<tr key={i} className="border-b border-term-border/40">
|
||
<td className="py-0.5 pr-2 text-term-muted">{label}</td>
|
||
<td className={`whitespace-nowrap py-0.5 text-right ${cls}`}>{money(amount)}</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
<tr>
|
||
<td className="py-1 pr-2 font-semibold text-term-text">{t("lab.bd.total")}</td>
|
||
<td className="whitespace-nowrap py-1 text-right font-semibold text-term-cyan">{money(b.totalMinor)}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|