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:
@@ -0,0 +1,604 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
isTariffV2,
|
||||
type TariffBlock,
|
||||
type TariffCard,
|
||||
type TariffStep,
|
||||
type TariffStructure,
|
||||
type TariffState,
|
||||
} from "./api.js";
|
||||
|
||||
// The tariff EDITOR FORM — the rate-card composer's form machinery (state shape,
|
||||
// structure↔form converters, and the editing UI), extracted so two hosts can share
|
||||
// it: the /setup/tariff page (edits + publishes the live card) and the Tariff Lab's
|
||||
// draft modal (edits an experimental card). The host owns the FormState and the
|
||||
// submit action; this module owns everything between. Amounts are entered in major
|
||||
// units (e.g. euros) and converted to integer minor units on submit.
|
||||
// See wiki/concepts/tariff.md.
|
||||
|
||||
// Editable form mirror of TariffStructure, but money in major-unit strings.
|
||||
// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the
|
||||
// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes.
|
||||
// The LAST block is always open-ended ("thereafter"): its hours field is unused
|
||||
// and it has no bound. On submit, per-block hours accumulate into the engine's
|
||||
// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null.
|
||||
export interface BlockForm {
|
||||
hours: string; // duration of THIS band, in hours (ignored for the last block)
|
||||
price: string; // major units, e.g. "2.00"
|
||||
}
|
||||
// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the
|
||||
// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md.
|
||||
export interface StepForm {
|
||||
hours: string; // inclusive upper bound of this tier, in hours (e.g. "3")
|
||||
total: string; // TOTAL major units for a stay within this tier (e.g. "5.00")
|
||||
}
|
||||
// A pricing body the form edits: a per-increment flat rate, a marginal block ladder,
|
||||
// a stepped (up-to) total-by-duration table, or a whole-window package (tiers only).
|
||||
export interface PricingForm {
|
||||
mode: "ladder" | "flat" | "stepped" | "package";
|
||||
flat: string; // major units PER INCREMENT (used when mode==="flat")
|
||||
packageTotal: string; // major units for the WHOLE window occurrence (mode==="package")
|
||||
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
|
||||
steps: StepForm[]; // up-to tiers (used when mode==="stepped")
|
||||
dailyCap: string; // "" = no cap (ladder only)
|
||||
}
|
||||
// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
|
||||
export interface TierForm {
|
||||
name: string;
|
||||
priority: string;
|
||||
category: string; // "" = applies to all categories
|
||||
dow: number[]; // selected days 0..6; empty = every day
|
||||
fromHour: string; // "" = all day
|
||||
toHour: string;
|
||||
dateFrom: string; // "" = unbounded
|
||||
dateTo: string;
|
||||
pricing: PricingForm;
|
||||
}
|
||||
export interface FormState {
|
||||
currency: string;
|
||||
gracePeriodEntryMin: string;
|
||||
incrementMin: string;
|
||||
lostTicket: string;
|
||||
gracePeriodExitMin: string;
|
||||
// The default (always-active) card — its own flat/ladder body + daily cap.
|
||||
base: PricingForm;
|
||||
// Optional time/category tiers. Empty ⇒ a bare V1 structure is published.
|
||||
tiers: TierForm[];
|
||||
}
|
||||
|
||||
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
||||
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
||||
|
||||
function emptySteps(): StepForm[] {
|
||||
return [
|
||||
{ hours: "1", total: "2.00" },
|
||||
{ hours: "3", total: "5.00" },
|
||||
];
|
||||
}
|
||||
function emptyLadder(): PricingForm {
|
||||
return {
|
||||
mode: "ladder",
|
||||
flat: "0.00",
|
||||
packageTotal: "0.00",
|
||||
dailyCap: "",
|
||||
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
|
||||
steps: emptySteps(),
|
||||
};
|
||||
}
|
||||
function emptyTier(): TierForm {
|
||||
return {
|
||||
name: "",
|
||||
priority: "10",
|
||||
category: "",
|
||||
dow: [],
|
||||
fromHour: "",
|
||||
toHour: "",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] },
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyForm(): FormState {
|
||||
return {
|
||||
currency: "EUR",
|
||||
gracePeriodEntryMin: "15",
|
||||
incrementMin: "60",
|
||||
lostTicket: "20.00",
|
||||
gracePeriodExitMin: "15",
|
||||
base: emptyLadder(),
|
||||
tiers: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band
|
||||
// hours the form edits. Open-ended last band has no hours. Legacy bounded tails still
|
||||
// load (shown as their own band).
|
||||
function blocksToForm(blocks: TariffBlock[]): BlockForm[] {
|
||||
let prev = 0;
|
||||
return blocks.map((b) => {
|
||||
if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) };
|
||||
const hours = (b.uptoMin - prev) / 60;
|
||||
prev = b.uptoMin;
|
||||
return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) };
|
||||
});
|
||||
}
|
||||
|
||||
// A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits.
|
||||
function stepsToForm(steps: TariffStep[]): StepForm[] {
|
||||
return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) }));
|
||||
}
|
||||
|
||||
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, stepped,
|
||||
// or window package).
|
||||
function pricingFromCard(c: {
|
||||
flatMinor?: number;
|
||||
blocks?: TariffBlock[];
|
||||
steps?: TariffStep[];
|
||||
packageMinor?: number;
|
||||
dailyCapMinor?: number | null;
|
||||
}): PricingForm {
|
||||
if (c.steps != null && c.steps.length > 0) {
|
||||
return { ...emptyLadder(), mode: "stepped", steps: stepsToForm(c.steps) };
|
||||
}
|
||||
if (c.packageMinor != null) {
|
||||
return { ...emptyLadder(), mode: "package", packageTotal: toMajor(c.packageMinor) };
|
||||
}
|
||||
if (c.flatMinor != null) {
|
||||
return { ...emptyLadder(), mode: "flat", flat: toMajor(c.flatMinor) };
|
||||
}
|
||||
return {
|
||||
...emptyLadder(),
|
||||
mode: "ladder",
|
||||
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
||||
blocks: blocksToForm(c.blocks ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
function tierFromCard(c: TariffCard): TierForm {
|
||||
const w = c.window ?? {};
|
||||
return {
|
||||
name: c.name,
|
||||
priority: String(c.priority),
|
||||
category: c.category ?? "",
|
||||
dow: w.dow ? [...w.dow] : [],
|
||||
fromHour: w.fromHour ?? "",
|
||||
toHour: w.toHour ?? "",
|
||||
dateFrom: w.dateFrom ?? "",
|
||||
dateTo: w.dateTo ?? "",
|
||||
pricing: pricingFromCard(c),
|
||||
};
|
||||
}
|
||||
|
||||
/** A stored (currency, structure) pair → the editable form. Used to load the active
|
||||
* version into the composer page and a saved draft into the lab modal. */
|
||||
export function formFromVersion(currency: string, st: TariffStructure): FormState {
|
||||
const common = {
|
||||
currency,
|
||||
gracePeriodEntryMin: String(st.gracePeriodEntryMin),
|
||||
incrementMin: String(st.incrementMin),
|
||||
lostTicket: toMajor(st.lostTicketMinor),
|
||||
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
||||
};
|
||||
if (isTariffV2(st)) {
|
||||
return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) };
|
||||
}
|
||||
// V1: the bare ladder becomes the default card body; no tiers.
|
||||
return { ...common, base: pricingFromCard(st), tiers: [] };
|
||||
}
|
||||
|
||||
export function formFromActive(s: TariffState): FormState {
|
||||
return s.active ? formFromVersion(s.active.currency, s.active.structure) : emptyForm();
|
||||
}
|
||||
|
||||
// Build a tariff card's pricing body (flat XOR ladder XOR stepped XOR package) from a PricingForm.
|
||||
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "steps" | "packageMinor" | "dailyCapMinor"> {
|
||||
if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
|
||||
if (p.mode === "package") return { packageMinor: toMinor(p.packageTotal) };
|
||||
if (p.mode === "stepped") {
|
||||
// Each row's `hours` IS the inclusive threshold (the matrix "up to N hours").
|
||||
const steps: TariffStep[] = p.steps.map((s) => ({
|
||||
uptoMin: Math.round(Number(s.hours || "0") * 60),
|
||||
totalMinor: toMinor(s.total),
|
||||
}));
|
||||
return { steps };
|
||||
}
|
||||
// Accumulate each band's hours into cumulative uptoMin (min); last band open-ended.
|
||||
const last = p.blocks.length - 1;
|
||||
let cum = 0;
|
||||
const blocks: TariffBlock[] = p.blocks.map((b, i) => {
|
||||
if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
|
||||
cum += Math.round(Number(b.hours || "0") * 60);
|
||||
return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) };
|
||||
});
|
||||
return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) };
|
||||
}
|
||||
|
||||
function tierToCard(tr: TierForm): TariffCard {
|
||||
const window: TariffCard["window"] = {};
|
||||
if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b);
|
||||
if (tr.fromHour && tr.toHour) {
|
||||
window.fromHour = tr.fromHour;
|
||||
window.toHour = tr.toHour;
|
||||
}
|
||||
if (tr.dateFrom) window.dateFrom = tr.dateFrom;
|
||||
if (tr.dateTo) window.dateTo = tr.dateTo;
|
||||
const card: TariffCard = {
|
||||
name: tr.name.trim() || "tier",
|
||||
priority: Math.round(Number(tr.priority || "0")),
|
||||
...pricingToCardBody(tr.pricing),
|
||||
};
|
||||
if (tr.category.trim()) card.category = tr.category.trim();
|
||||
if (Object.keys(window).length > 0) card.window = window;
|
||||
return card;
|
||||
}
|
||||
|
||||
export function toStructure(f: FormState): TariffStructure {
|
||||
const common = {
|
||||
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
|
||||
incrementMin: Math.round(Number(f.incrementMin)),
|
||||
lostTicketMinor: toMinor(f.lostTicket),
|
||||
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
|
||||
overstay: "reprice" as const,
|
||||
};
|
||||
const baseBody = pricingToCardBody(f.base);
|
||||
|
||||
// NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants
|
||||
// tiers gets exactly today's shape; the server leaves it untouched).
|
||||
if (f.tiers.length === 0) {
|
||||
if (f.base.mode === "stepped") {
|
||||
// A stepped V1: the up-to table replaces the ladder (blocks empty, no cap).
|
||||
return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null };
|
||||
}
|
||||
if (f.base.mode === "flat") {
|
||||
// A flat V1: a single open-ended block at the flat rate (V1 has no flat field).
|
||||
return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null };
|
||||
}
|
||||
return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null };
|
||||
}
|
||||
|
||||
// Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here).
|
||||
return {
|
||||
...common,
|
||||
version: 2,
|
||||
tz: "",
|
||||
defaultCard: { name: "default", priority: 0, ...baseBody },
|
||||
windowedCards: f.tiers.map(tierToCard),
|
||||
};
|
||||
}
|
||||
|
||||
/** The full rate-card editing UI (shared settings + default card + tiers). The host
|
||||
* owns the FormState; every edit flows through `onChange` as a functional update. */
|
||||
export function TariffEditorForm({
|
||||
form,
|
||||
onChange,
|
||||
}: {
|
||||
form: FormState;
|
||||
onChange: (update: (f: FormState) => FormState) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
onChange((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
// --- pricing-body editing (used by the default card AND each tier) ---
|
||||
// `update` maps the old PricingForm to a new one; `target` selects which body:
|
||||
// the base card, or tier index N.
|
||||
function updatePricing(target: "base" | number, update: (p: PricingForm) => PricingForm) {
|
||||
onChange((f) => {
|
||||
if (target === "base") return { ...f, base: update(f.base) };
|
||||
return { ...f, tiers: f.tiers.map((tr, j) => (j === target ? { ...tr, pricing: update(tr.pricing) } : tr)) };
|
||||
});
|
||||
}
|
||||
function setBlock(target: "base" | number, i: number, patch: Partial<BlockForm>) {
|
||||
updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
|
||||
}
|
||||
// Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended.
|
||||
function addBlock(target: "base" | number) {
|
||||
updatePricing(target, (p) => {
|
||||
const next = [...p.blocks];
|
||||
next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" });
|
||||
return { ...p, blocks: next };
|
||||
});
|
||||
}
|
||||
function removeBlock(target: "base" | number, i: number) {
|
||||
updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) }));
|
||||
}
|
||||
// --- stepped (up-to) editing (base card only) ---
|
||||
function setStep(i: number, patch: Partial<StepForm>) {
|
||||
updatePricing("base", (p) => ({ ...p, steps: p.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
|
||||
}
|
||||
function addStep() {
|
||||
updatePricing("base", (p) => ({ ...p, steps: [...p.steps, { hours: "", total: "0.00" }] }));
|
||||
}
|
||||
function removeStep(i: number) {
|
||||
updatePricing("base", (p) => (p.steps.length <= 1 ? p : { ...p, steps: p.steps.filter((_, j) => j !== i) }));
|
||||
}
|
||||
|
||||
// --- tier editing ---
|
||||
function setTier(i: number, patch: Partial<TierForm>) {
|
||||
onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
|
||||
}
|
||||
function addTier() {
|
||||
onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] }));
|
||||
}
|
||||
function removeTier(i: number) {
|
||||
onChange((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
|
||||
}
|
||||
function toggleDow(i: number, d: number) {
|
||||
onChange((f) => ({
|
||||
...f,
|
||||
tiers: f.tiers.map((tr, j) =>
|
||||
j === i ? { ...tr, dow: tr.dow.includes(d) ? tr.dow.filter((x) => x !== d) : [...tr.dow, d] } : tr,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||
<label className="label">{t("tariff.currency")}</label>
|
||||
<input className="input w-24" value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} />
|
||||
<label className="label">{t("tariff.freeEntryGrace")}</label>
|
||||
<input className="input w-32" value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
||||
<label className="label">{t("tariff.billingIncrement")}</label>
|
||||
<input className="input w-32" value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
||||
<label className="label">{t("tariff.lostTicketFee")}</label>
|
||||
<input className="input w-32" value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
||||
<label className="label">{t("tariff.exitGrace")}</label>
|
||||
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
|
||||
wants tiers just edits this and publishes a bare V1 structure. */}
|
||||
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
|
||||
<p className="hint mb-2">{t("tariff.defaultCardHint")}</p>
|
||||
<div className="card card-body">
|
||||
<PricingEditor
|
||||
t={t}
|
||||
pricing={form.base}
|
||||
allowStepped
|
||||
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
||||
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
||||
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
|
||||
onBlock={(i, patch) => setBlock("base", i, patch)}
|
||||
onAddBlock={() => addBlock("base")}
|
||||
onRemoveBlock={(i) => removeBlock("base", i)}
|
||||
onStep={setStep}
|
||||
onAddStep={addStep}
|
||||
onRemoveStep={removeStep}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
|
||||
<details className="mt-6" open={form.tiers.length > 0}>
|
||||
<summary className="cursor-pointer text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.tiersAdvanced")}</summary>
|
||||
<p className="hint mt-1.5 mb-2">{t("tariff.tiersHint")}</p>
|
||||
{/* A stepped ("up-to") base rate cannot be combined with time tiers — the
|
||||
engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
|
||||
{form.base.mode === "stepped" && form.tiers.length > 0 && (
|
||||
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[0.75rem] text-term-red">
|
||||
{t("tariff.steppedTiersConflict")}
|
||||
</p>
|
||||
)}
|
||||
{form.tiers.map((tr, i) => (
|
||||
<fieldset key={i} className="card mb-3 p-4">
|
||||
<legend className="flex items-center gap-2 px-1">
|
||||
<input
|
||||
className="input w-40"
|
||||
value={tr.name}
|
||||
onChange={(e) => setTier(i, { name: e.target.value })}
|
||||
placeholder={t("tariff.tierName")}
|
||||
/>
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeTier(i)}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
</legend>
|
||||
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||
<label className="label">{t("tariff.tierPriority")}</label>
|
||||
<input className="input w-20" value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} />
|
||||
<label className="label">{t("tariff.tierCategory")}</label>
|
||||
<input className="input w-40" value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} />
|
||||
<label className="label">{t("tariff.tierDays")}</label>
|
||||
<span className="flex flex-wrap gap-2">
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
||||
<label key={d} className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
|
||||
{t(`tariff.dow${d}`)}
|
||||
</label>
|
||||
))}
|
||||
</span>
|
||||
<label className="label">{t("tariff.tierHours")}</label>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input className="input w-20" value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" />
|
||||
<span className="text-term-muted">–</span>
|
||||
<input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" />
|
||||
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
|
||||
<span className="text-[0.6875rem] text-term-muted">{t("tariff.tierOvernight")}</span>
|
||||
)}
|
||||
</span>
|
||||
<label className="label">{t("tariff.tierDates")}</label>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input type="date" className="input w-40" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
|
||||
<span className="text-term-muted">–</span>
|
||||
<input type="date" className="input w-40" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 border-t border-term-border pt-3">
|
||||
<PricingEditor
|
||||
t={t}
|
||||
pricing={tr.pricing}
|
||||
allowPackage
|
||||
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
|
||||
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
|
||||
onPackage={(packageTotal) => updatePricing(i, (p) => ({ ...p, packageTotal }))}
|
||||
onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))}
|
||||
onBlock={(bi, patch) => setBlock(i, bi, patch)}
|
||||
onAddBlock={() => addBlock(i)}
|
||||
onRemoveBlock={(bi) => removeBlock(i, bi)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
<button type="button" className="btn btn-sm" onClick={addTier}>
|
||||
{t("tariff.addTier")}
|
||||
</button>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A reusable pricing-body editor — flat (per increment) / marginal ladder / stepped
|
||||
// (up-to) / window package. The stepped mode is offered only where `allowStepped`
|
||||
// (the default card); the package mode only where `allowPackage` (tier cards — the
|
||||
// engine needs a window to be an occurrence of).
|
||||
function PricingEditor(props: {
|
||||
t: (k: string) => string;
|
||||
pricing: PricingForm;
|
||||
allowStepped?: boolean;
|
||||
allowPackage?: boolean;
|
||||
onMode: (m: "ladder" | "flat" | "stepped" | "package") => void;
|
||||
onFlat: (v: string) => void;
|
||||
onPackage?: (v: string) => void;
|
||||
onCap: (v: string) => void;
|
||||
onBlock: (i: number, patch: Partial<BlockForm>) => void;
|
||||
onAddBlock: () => void;
|
||||
onRemoveBlock: (i: number) => void;
|
||||
onStep?: (i: number, patch: Partial<StepForm>) => void;
|
||||
onAddStep?: () => void;
|
||||
onRemoveStep?: (i: number) => void;
|
||||
}) {
|
||||
const { t, pricing: p } = props;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap gap-4 text-[0.75rem]">
|
||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
|
||||
{t("tariff.modeLadder")}
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
||||
{t("tariff.modeFlat")}
|
||||
</label>
|
||||
{props.allowStepped && (
|
||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||
<input type="radio" className="accent-term-amber" checked={p.mode === "stepped"} onChange={() => props.onMode("stepped")} />
|
||||
{t("tariff.modeStepped")}
|
||||
</label>
|
||||
)}
|
||||
{props.allowPackage && (
|
||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||
<input type="radio" className="accent-term-amber" checked={p.mode === "package"} onChange={() => props.onMode("package")} />
|
||||
{t("tariff.modePackage")}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{p.mode === "package" ? (
|
||||
<div>
|
||||
<p className="hint mb-2">{t("tariff.packageHint")}</p>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="label">{t("tariff.packageTotal")}</span>
|
||||
<input className="input w-28" value={p.packageTotal} onChange={(e) => props.onPackage?.(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
) : p.mode === "stepped" ? (
|
||||
<>
|
||||
<p className="hint mb-2">{t("tariff.steppedHint")}</p>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="text-left">
|
||||
<th className="label px-2 pb-1 font-normal">{t("tariff.stepUpTo")}</th>
|
||||
<th className="label px-2 pb-1 font-normal">{t("tariff.stepTotal")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{p.steps.map((s, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-2 py-1">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input className="input w-20" value={s.hours} onChange={(e) => props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
||||
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<input className="input w-28" value={s.total} onChange={(e) => props.onStep?.(i, { total: e.target.value })} />
|
||||
</td>
|
||||
<td className="px-2">
|
||||
{p.steps.length > 1 && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveStep?.(i)}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-3">
|
||||
<button type="button" className="btn btn-sm" onClick={props.onAddStep}>
|
||||
{t("tariff.addStep")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : p.mode === "flat" ? (
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="label">{t("tariff.pricePerIncrement")}</span>
|
||||
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="text-left">
|
||||
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
|
||||
<th className="label px-2 pb-1 font-normal">{t("tariff.pricePerIncrement")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{p.blocks.map((b, i) => {
|
||||
const isTail = i === p.blocks.length - 1;
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td className="px-2 py-1">
|
||||
{isTail ? (
|
||||
<span className="italic text-term-muted">{t("tariff.thereafter")}</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
||||
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
|
||||
</td>
|
||||
<td className="px-2">
|
||||
{!isTail && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveBlock(i)}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-3 flex items-center gap-4">
|
||||
<button type="button" className="btn btn-sm" onClick={props.onAddBlock}>
|
||||
{t("tariff.addBlock")}
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="label">{t("tariff.dailyCap")}</span>
|
||||
<input className="input w-28" value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} />
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user