import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { ApiError, fetchTariff, isTariffV2, publishTariffVersion, type TariffBlock, type TariffCard, type TariffStep, type TariffStructure, type TariffState, } from "./api.js"; // Tariff composer — the admin builds + edits the rate card at runtime. Publishing // creates a new IMMUTABLE version (the active card); old versions are kept so past // sessions reprice correctly. Amounts are entered in major units (e.g. euros) for // usability 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. 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. 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 flat rate, a marginal block ladder, or a stepped // (up-to) total-by-duration table. interface PricingForm { mode: "ladder" | "flat" | "stepped"; flat: string; // major units (used when mode==="flat") 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. 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; } 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", 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" }] }, }; } 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, or stepped). function pricingFromCard(c: { flatMinor?: number; blocks?: TariffBlock[]; steps?: TariffStep[]; dailyCapMinor?: number | null; }): PricingForm { if (c.steps != null && c.steps.length > 0) { return { mode: "stepped", flat: "0.00", dailyCap: "", blocks: emptyLadder().blocks, steps: stepsToForm(c.steps) }; } if (c.flatMinor != null) { return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks, steps: emptySteps() }; } return { mode: "ladder", flat: "0.00", dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor), blocks: blocksToForm(c.blocks ?? []), steps: emptySteps(), }; } 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), }; } function formFromActive(s: TariffState): FormState { const v = s.active; if (!v) return emptyForm(); const st = v.structure; const common = { currency: v.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: [] }; } // Build a tariff card's pricing body (flat XOR ladder XOR stepped) from a PricingForm. function pricingToCardBody(p: PricingForm): Pick { if (p.mode === "flat") return { flatMinor: toMinor(p.flat) }; 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; } 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), }; } export function TariffComposer() { const { t } = useTranslation(); const [state, setState] = useState(null); const [form, setForm] = useState(emptyForm); 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)); }) .catch((e) => setMsg({ kind: "err", text: (e as Error).message })); }, []); function set(key: K, value: FormState[K]) { setForm((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) { setForm((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) { 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) { 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) { setForm((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) })); } function addTier() { setForm((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] })); } function removeTier(i: number) { setForm((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) })); } function toggleDow(i: number, d: number) { setForm((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, ), })); } async function publish() { setSaving(true); setMsg(null); try { await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) }); const fresh = await fetchTariff(); setState(fresh); 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 (

{t("tariff.title")}

{!state?.active ? (

{t("tariff.noRateCard")}

) : (

{t("tariff.activeSince", { date: new Date(state.active.effectiveFrom).toLocaleString(), count: state.versions.length, })}

)}
set("currency", e.target.value)} maxLength={3} /> set("gracePeriodEntryMin", e.target.value)} /> set("incrementMin", e.target.value)} /> set("lostTicket", e.target.value)} /> set("gracePeriodExitMin", e.target.value)} />
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never wants tiers just edits this and publishes a bare V1 structure. */}

{t("tariff.defaultCard")}

{t("tariff.defaultCardHint")}

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} />
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
0}> {t("tariff.tiersAdvanced")}

{t("tariff.tiersHint")}

{/* 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 && (

{t("tariff.steppedTiersConflict")}

)} {form.tiers.map((tr, i) => (
setTier(i, { name: e.target.value })} placeholder={t("tariff.tierName")} />
setTier(i, { priority: e.target.value })} /> setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} /> {[1, 2, 3, 4, 5, 6, 0].map((d) => ( ))} setTier(i, { fromHour: e.target.value })} placeholder="22:00" /> – setTier(i, { toHour: e.target.value })} placeholder="06:00" /> {tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && ( {t("tariff.tierOvernight")} )} setTier(i, { dateFrom: e.target.value })} /> – setTier(i, { dateTo: e.target.value })} />
updatePricing(i, (p) => ({ ...p, mode }))} onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))} onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))} onBlock={(bi, patch) => setBlock(i, bi, patch)} onAddBlock={() => addBlock(i)} onRemoveBlock={(bi) => removeBlock(i, bi)} />
))}
{msg && ( {msg.text} )}
); } // A reusable pricing-body editor — flat / marginal ladder / stepped (up-to). The // stepped mode is offered only where `allowStepped` (the default card, not tiers). function PricingEditor(props: { t: (k: string) => string; pricing: PricingForm; allowStepped?: boolean; onMode: (m: "ladder" | "flat" | "stepped") => void; onFlat: (v: string) => void; onCap: (v: string) => void; onBlock: (i: number, patch: Partial) => void; onAddBlock: () => void; onRemoveBlock: (i: number) => void; onStep?: (i: number, patch: Partial) => void; onAddStep?: () => void; onRemoveStep?: (i: number) => void; }) { const { t, pricing: p } = props; return (
{props.allowStepped && ( )}
{p.mode === "stepped" ? ( <>

{t("tariff.steppedHint")}

{p.steps.map((s, i) => ( ))}
{t("tariff.stepUpTo")} {t("tariff.stepTotal")}
props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} /> {t("tariff.hoursUnit")} props.onStep?.(i, { total: e.target.value })} /> {p.steps.length > 1 && ( )}
) : p.mode === "flat" ? (
{t("tariff.pricePerIncrement")} props.onFlat(e.target.value)} />
) : ( <> {p.blocks.map((b, i) => { const isTail = i === p.blocks.length - 1; return ( ); })}
{t("tariff.bandDuration")} {t("tariff.pricePerIncrement")}
{isTail ? ( {t("tariff.thereafter")} ) : ( props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} /> {t("tariff.hoursUnit")} )} props.onBlock(i, { price: e.target.value })} /> {!isTail && ( )}
{t("tariff.dailyCap")} props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} />
)}
); }