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
This commit is contained in:
@@ -1,19 +1,23 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ApiError, fetchTariff, publishTariffVersion, type TariffState } from "./api.js";
|
import { ApiError, fetchTariff, publishTariffVersion, type TariffState, type TariffVersion } from "./api.js";
|
||||||
import { TariffEditorForm, emptyForm, formFromActive, toStructure, type FormState } from "./TariffEditorForm.js";
|
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
||||||
|
|
||||||
// Tariff composer — the admin edits + publishes the LIVE rate card. Publishing
|
// 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
|
// creates a new IMMUTABLE version (the active card); old versions are kept so past
|
||||||
// sessions reprice correctly. The form machinery is shared with the Tariff Lab's
|
// sessions reprice correctly. A right sidebar lists the published history (named
|
||||||
// draft modal — see TariffEditorForm.tsx. To experiment without publishing, use the
|
// since 2026-07-05); clicking a version loads it into the editor as the STARTING
|
||||||
// lab (a draft only becomes real through this same publish path). See
|
// 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.
|
// wiki/concepts/tariff.md.
|
||||||
|
|
||||||
export function TariffComposer() {
|
export function TariffComposer() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<TariffState | null>(null);
|
const [state, setState] = useState<TariffState | null>(null);
|
||||||
const [form, setForm] = useState<FormState>(emptyForm);
|
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
|
// 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
|
// from the active version — a tweaked card republished under last season's name
|
||||||
// would mislabel the history.
|
// would mislabel the history.
|
||||||
@@ -26,10 +30,17 @@ export function TariffComposer() {
|
|||||||
.then((s) => {
|
.then((s) => {
|
||||||
setState(s);
|
setState(s);
|
||||||
setForm(formFromActive(s));
|
setForm(formFromActive(s));
|
||||||
|
setLoadedId(s.active?.id ?? null);
|
||||||
})
|
})
|
||||||
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
.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() {
|
async function publish() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
@@ -41,6 +52,7 @@ export function TariffComposer() {
|
|||||||
});
|
});
|
||||||
const fresh = await fetchTariff();
|
const fresh = await fetchTariff();
|
||||||
setState(fresh);
|
setState(fresh);
|
||||||
|
setLoadedId(fresh.active?.id ?? null);
|
||||||
setVersionName("");
|
setVersionName("");
|
||||||
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -71,20 +83,66 @@ export function TariffComposer() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TariffEditorForm form={form} onChange={setForm} />
|
<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">
|
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||||
<input
|
<input
|
||||||
className="input w-64"
|
className="input w-64"
|
||||||
value={versionName}
|
value={versionName}
|
||||||
onChange={(e) => setVersionName(e.target.value)}
|
onChange={(e) => setVersionName(e.target.value)}
|
||||||
placeholder={t("tariff.versionNamePh")}
|
placeholder={t("tariff.versionNamePh")}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="btn btn-primary btn-lg" onClick={publish} disabled={saving}>
|
<button type="button" className="btn btn-primary btn-lg" onClick={publish} disabled={saving}>
|
||||||
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
||||||
</button>
|
</button>
|
||||||
{msg && (
|
{msg && (
|
||||||
<span className={msg.kind === "ok" ? "text-[0.75rem] text-term-green" : "text-[0.75rem] text-term-red"}>{msg.text}</span>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -336,6 +336,9 @@ export const en: Catalog = {
|
|||||||
publishNewVersion: "Publish new version",
|
publishNewVersion: "Publish new version",
|
||||||
publishing: "Publishing…",
|
publishing: "Publishing…",
|
||||||
versionNamePh: "Version name (optional), e.g. Summer 2026",
|
versionNamePh: "Version name (optional), e.g. Summer 2026",
|
||||||
|
versionsTitle: "Published versions",
|
||||||
|
versionsHint: "Click one to load it into the editor. Publishing always creates a new version — past versions never change.",
|
||||||
|
activeBadge: "active",
|
||||||
publishedOk: "New tariff version published — it's now the active rate.",
|
publishedOk: "New tariff version published — it's now the active rate.",
|
||||||
defaultCard: "Base rate (always active)",
|
defaultCard: "Base rate (always active)",
|
||||||
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
|
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
|
||||||
|
|||||||
@@ -339,6 +339,9 @@ export const sq = {
|
|||||||
publishNewVersion: "Publiko version të ri",
|
publishNewVersion: "Publiko version të ri",
|
||||||
publishing: "Duke publikuar…",
|
publishing: "Duke publikuar…",
|
||||||
versionNamePh: "Emri i versionit (opsional), p.sh. Vera 2026",
|
versionNamePh: "Emri i versionit (opsional), p.sh. Vera 2026",
|
||||||
|
versionsTitle: "Versione të publikuara",
|
||||||
|
versionsHint: "Kliko një për ta ngarkuar në editor. Publikimi krijon gjithmonë version të ri — versionet e kaluara nuk ndryshojnë kurrë.",
|
||||||
|
activeBadge: "aktive",
|
||||||
publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.",
|
publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.",
|
||||||
defaultCard: "Tarifa bazë (gjithmonë aktive)",
|
defaultCard: "Tarifa bazë (gjithmonë aktive)",
|
||||||
defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.",
|
defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.",
|
||||||
|
|||||||
@@ -193,7 +193,11 @@ The admin authors the rate card at runtime — no hand-seeding:
|
|||||||
composer accumulates per-band hours into the engine's cumulative `uptoMin` (minutes) on submit. The
|
composer accumulates per-band hours into the engine's cumulative `uptoMin` (minutes) on submit. The
|
||||||
**last band is always the open-ended "thereafter"** row (not removable, no hours field), so a
|
**last band is always the open-ended "thereafter"** row (not removable, no hours field), so a
|
||||||
published card always satisfies the open-ended-last rule. Shows the active version + history;
|
published card always satisfies the open-ended-last rule. Shows the active version + history;
|
||||||
"Publish" creates a new version (past sessions keep their pricing).
|
"Publish" creates a new version (past sessions keep their pricing). Since 2026-07-05 a **right
|
||||||
|
sidebar lists the published history** (name or effective date, active badge — mirrors the lab's
|
||||||
|
sidebar); clicking a version **loads it into the editor as the starting point** for the next
|
||||||
|
publish (currency select ALL/EUR/USD; optional version-name field). Publishing never edits the
|
||||||
|
clicked version — the sidebar hint says so explicitly.
|
||||||
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
|
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
|
||||||
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
|
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user