feat(tariff-lab): DB-backed draft tariffs + named published versions
Experimenting used to mean publishing — churning the immutable version
history and risking real tickets pricing against a half-baked card while
the admin iterated. The lab is now a true sandbox:
- tariff_drafts table (migration 0021): MUTABLE by design — the one
exception to "editing publishes a version"; a draft prices nothing and
signs nothing. Drafts are validated + tz-stamped on save exactly like a
publish, so a saved draft always simulates and never fails at publish.
- CRUD under /api/tariff/drafts (list tariff:read, mutations
tariff:update); publishing a draft goes through the normal immutable
POST /api/tariff/versions path.
- Lab UI rebuilt: sidebar lists lab drafts AND the full published history
(click any to price against it); main pane cut to pure entry/exit
(ticket loader, payment, category inputs dropped); the composer form is
extracted to TariffEditorForm.tsx and reused in a modal (new drafts
prefill from the active card); per-draft Publish with confirm.
- tariff_versions.name (migration 0022): optional label stamped at
publish — carried from the lab draft, or typed in the composer's new
optional field — so history reads "Winter 2027", not UUID prefixes.
- Includes the composer UI + sq/en labels for the package mode (engine
landed in d9e6c13) and the "Flat price / hour" relabel.
5 new server integration tests (RBAC, roundtrip, validation, tz-stamp +
simulate + publish w/ name); server suite 288 green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
+329
-165
@@ -1,21 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ApiError,
|
||||
createTariffDraft,
|
||||
deleteTariffDraft,
|
||||
fetchTariff,
|
||||
loadSimSession,
|
||||
fetchTariffDrafts,
|
||||
publishTariffVersion,
|
||||
simulateTariff,
|
||||
updateTariffDraft,
|
||||
type SimulateResult,
|
||||
type SimPayment,
|
||||
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 { formatMoney, formatDuration } from "./lib/format.js";
|
||||
|
||||
// The TARIFF LAB — a pure session-pricing simulator. Test rates "in time" (overnight
|
||||
// windows, daily caps, overstay) in seconds instead of waiting hours, against ANY
|
||||
// published tariff version, with no real ledger writes. Build a hypothetical session
|
||||
// (entry, optional payment, "now") OR load a real ticket and re-evaluate it at any
|
||||
// instant. Prices via the SAME `priceSession` the booth uses (server), so the lab and
|
||||
// the live booth can never diverge. See wiki/concepts/tariff.md, booth-exit-flow.md.
|
||||
// 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 {
|
||||
@@ -33,50 +42,76 @@ 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);
|
||||
|
||||
// Inputs (datetime-local strings, local wall-clock).
|
||||
// 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 [asOf, setAsOf] = useState<string>(nowLocal);
|
||||
const [category, setCategory] = useState("");
|
||||
const [versionId, setVersionId] = useState<string>(""); // "" = active
|
||||
// Optional single hypothetical payment (the latest grants the walk-back grace).
|
||||
const [paid, setPaid] = useState(false);
|
||||
const [paidAt, setPaidAt] = useState<string>(nowLocal);
|
||||
const [graceMin, setGraceMin] = useState<string>("5");
|
||||
// Load-a-real-ticket.
|
||||
const [ticket, setTicket] = useState("");
|
||||
const [loadMsg, setLoadMsg] = useState<string | null>(null);
|
||||
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(() => {
|
||||
fetchTariff()
|
||||
.then(setState)
|
||||
.catch((e) => setErr((e as Error).message));
|
||||
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 payments: SimPayment[] = paid
|
||||
? [{ paidAt: fromLocalInput(paidAt), graceExitMin: graceMin.trim() === "" ? null : Number(graceMin) }]
|
||||
: [];
|
||||
const r = await simulateTariff({
|
||||
enteredAt: fromLocalInput(entered),
|
||||
asOf: fromLocalInput(asOf),
|
||||
payments,
|
||||
category: category.trim() || undefined,
|
||||
tariffVersionId: versionId || undefined,
|
||||
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) {
|
||||
@@ -87,162 +122,291 @@ export function TariffLab() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTicket() {
|
||||
setLoadMsg(null);
|
||||
// --- 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 {
|
||||
const s = await loadSimSession(ticket.trim());
|
||||
setEntered(toLocalInput(s.enteredAt));
|
||||
setAsOf(s.exitedAt ? toLocalInput(s.exitedAt) : nowLocal());
|
||||
setCategory(s.category ?? "");
|
||||
setVersionId(s.tariffVersionId ?? "");
|
||||
const last = s.payments.at(-1);
|
||||
if (last) {
|
||||
setPaid(true);
|
||||
setPaidAt(toLocalInput(last.paidAt));
|
||||
setGraceMin(last.graceExitMin != null ? String(last.graceExitMin) : "");
|
||||
} else {
|
||||
setPaid(false);
|
||||
}
|
||||
setLoadMsg(t("lab.loaded", { id: s.identity }));
|
||||
await deleteTariffDraft(d.id);
|
||||
await refresh();
|
||||
select({ kind: "active" });
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const currency = result?.currency ?? state?.active?.currency ?? "ALL";
|
||||
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>
|
||||
|
||||
{/* Load a real ticket */}
|
||||
<div className="card card-body mb-4 flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="label">{t("lab.loadTicket")}</label>
|
||||
<input
|
||||
className="input w-56"
|
||||
value={ticket}
|
||||
onChange={(e) => setTicket(e.target.value)}
|
||||
placeholder={t("lab.loadTicketPh")}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}>
|
||||
{t("lab.load")}
|
||||
</button>
|
||||
{loadMsg && <span className="text-[0.75rem] text-term-green">{loadMsg}</span>}
|
||||
</div>
|
||||
<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 ?? new Date(selectedVersion.effectiveFrom).toLocaleString()
|
||||
: 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>
|
||||
|
||||
{/* Hypothetical session inputs */}
|
||||
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||
<label className="label">{t("lab.tariffVersion")}</label>
|
||||
<select className="input w-full max-w-md" value={versionId} onChange={(e) => setVersionId(e.target.value)}>
|
||||
<option value="">{t("lab.activeVersion")}</option>
|
||||
{state?.versions.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{new Date(v.effectiveFrom).toLocaleString()} · {v.currency} · {v.id.slice(0, 8)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<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.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>
|
||||
|
||||
<label className="label">{t("lab.asOf")}</label>
|
||||
<span className="flex items-center gap-2">
|
||||
<input type="datetime-local" className="input w-64" value={asOf} onChange={(e) => setAsOf(e.target.value)} />
|
||||
<button type="button" className="btn btn-sm" onClick={() => setAsOf(nowLocal())}>
|
||||
{t("lab.now")}
|
||||
</button>
|
||||
</span>
|
||||
<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>
|
||||
|
||||
<label className="label">{t("lab.category")}</label>
|
||||
<input
|
||||
className="input w-40"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
placeholder={t("lab.categoryPh")}
|
||||
/>
|
||||
{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">{new Date(result.pricing.periodStart).toLocaleString()}</dd>
|
||||
{result.pricing.graceExpiresAt && (
|
||||
<>
|
||||
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
|
||||
<dd className="text-term-text">{new Date(result.pricing.graceExpiresAt).toLocaleString()}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<label className="label">{t("lab.payment")}</label>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||
<input type="checkbox" className="accent-term-amber" checked={paid} onChange={(e) => setPaid(e.target.checked)} />
|
||||
{t("lab.paid")}
|
||||
</label>
|
||||
{paid && (
|
||||
<>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="input w-64"
|
||||
value={paidAt}
|
||||
onChange={(e) => setPaidAt(e.target.value)}
|
||||
/>
|
||||
<span className="text-term-muted">{t("lab.graceMin")}</span>
|
||||
<input className="input w-20" value={graceMin} onChange={(e) => setGraceMin(e.target.value)} />
|
||||
</>
|
||||
{/* 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>
|
||||
)}
|
||||
</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(asOf))}
|
||||
{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">{new Date(result.pricing.periodStart).toLocaleString()}</dd>
|
||||
{result.pricing.graceExpiresAt && (
|
||||
<>
|
||||
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
|
||||
<dd className="text-term-text">{new Date(result.pricing.graceExpiresAt).toLocaleString()}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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} · {new Date(d.updatedAt).toLocaleString()}
|
||||
</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 ? new Date(state.active.effectiveFrom).toLocaleString() : 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 ?? new Date(v.effectiveFrom).toLocaleString()}
|
||||
</span>
|
||||
<span className="block text-[0.6875rem] text-term-muted">
|
||||
{v.name ? `${new Date(v.effectiveFrom).toLocaleString()} · ` : ""}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user