Files
parking_solution/apps/web/src/TariffComposer.tsx
T
julian 4f902d869e feat(web): published-versions sidebar on the composer page
The lab redesign gave only the lab tab the published-history sidebar;
the composer page was expected to have it too. /setup/tariff now lists
every published version (name or effective date, active badge, currency)
on the right; clicking one loads it into the editor as the SEED for the
next publish — which always creates a new immutable version (the sidebar
hint states this), making "roll back to last month's prices" a two-click
republish while the history stays append-only.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 16:37:53 +02:00

151 lines
6.2 KiB
TypeScript

import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApiError, fetchTariff, publishTariffVersion, type TariffState, type TariffVersion } from "./api.js";
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
// Tariff composer — the admin edits + publishes the LIVE rate card. Publishing
// creates a new IMMUTABLE version (the active card); old versions are kept so past
// sessions reprice correctly. A right sidebar lists the published history (named
// since 2026-07-05); clicking a version loads it into the editor as the STARTING
// POINT — publishing always creates a new version effective now, it never edits the
// clicked one. The form machinery is shared with the Tariff Lab's draft modal — see
// TariffEditorForm.tsx. To experiment without publishing, use the lab. See
// wiki/concepts/tariff.md.
export function TariffComposer() {
const { t } = useTranslation();
const [state, setState] = useState<TariffState | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
// Which published version the editor was last loaded from (sidebar highlight).
const [loadedId, setLoadedId] = useState<string | null>(null);
// Optional label for the version about to be published. Deliberately NOT prefilled
// from the active version — a tweaked card republished under last season's name
// would mislabel the history.
const [versionName, setVersionName] = useState("");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
useEffect(() => {
fetchTariff()
.then((s) => {
setState(s);
setForm(formFromActive(s));
setLoadedId(s.active?.id ?? null);
})
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
}, []);
function loadVersion(v: TariffVersion) {
setForm(formFromVersion(v.currency, v.structure));
setLoadedId(v.id);
setMsg(null);
}
async function publish() {
setSaving(true);
setMsg(null);
try {
await publishTariffVersion({
currency: form.currency.trim().toUpperCase(),
structure: toStructure(form),
...(versionName.trim() ? { name: versionName.trim() } : {}),
});
const fresh = await fetchTariff();
setState(fresh);
setLoadedId(fresh.active?.id ?? null);
setVersionName("");
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
} catch (e) {
const text =
e instanceof ApiError && e.problems?.length
? `${e.message}: ${e.problems.join("; ")}`
: (e as Error).message;
setMsg({ kind: "err", text });
} finally {
setSaving(false);
}
}
return (
<section className="px-4 py-6">
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2>
{!state?.active ? (
<p className="mb-4 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[0.75rem] text-term-amber">
{t("tariff.noRateCard")}
</p>
) : (
<p className="mb-4 text-[0.75rem] text-term-muted">
{state.active.name ? `${state.active.name} — ` : ""}
{t("tariff.activeSince", {
date: new Date(state.active.effectiveFrom).toLocaleString(),
count: state.versions.length,
})}
</p>
)}
<div className="flex flex-col gap-4 lg:flex-row">
<div className="min-w-0 flex-1">
<TariffEditorForm form={form} onChange={setForm} />
<div className="mt-6 flex flex-wrap items-center gap-3">
<input
className="input w-64"
value={versionName}
onChange={(e) => setVersionName(e.target.value)}
placeholder={t("tariff.versionNamePh")}
/>
<button type="button" className="btn btn-primary btn-lg" onClick={publish} disabled={saving}>
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
</button>
{msg && (
<span className={msg.kind === "ok" ? "text-[0.75rem] text-term-green" : "text-[0.75rem] text-term-red"}>{msg.text}</span>
)}
</div>
</div>
{/* Published history — click a version to load it into the editor. Same list
the lab's sidebar shows; here it seeds the next publish. */}
{state && state.versions.length > 0 && (
<aside className="w-full shrink-0 lg:w-72">
<h3 className="mb-1 text-h6 font-semibold uppercase tracking-wider text-term-text">
{t("tariff.versionsTitle")}
</h3>
<p className="hint mb-2">{t("tariff.versionsHint")}</p>
<ul className="flex flex-col gap-1">
{state.versions.map((v) => {
const isActive = v.id === state.active?.id;
return (
<li key={v.id}>
<button
type="button"
onClick={() => loadVersion(v)}
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
loadedId === v.id
? "border-term-amber bg-term-amber/10 text-term-text"
: "border-term-border text-term-muted hover:text-term-text"
}`}
>
<span className="flex items-center gap-2 font-semibold">
{v.name ?? new Date(v.effectiveFrom).toLocaleString()}
{isActive && (
<span className="rounded border border-term-green px-1 text-[0.625rem] uppercase text-term-green">
{t("tariff.activeBadge")}
</span>
)}
</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>
</section>
);
}