Files
parking_solution/apps/web/src/TariffEditorForm.tsx
T
julian ab968eb25e feat(web): composer states the billing unit — the 60→10 price trap closed
Ladder/flat prices are PER BILLING INCREMENT, but the form said only
"Çmimi / interval" — so changing the increment 60→10 silently multiplied
every price ×6 (operator walked into it). Now:

- Price headers name the real unit live: "Çmimi / orë" at 60,
  "Çmimi / N min" otherwise (flat-mode radio label likewise).
- Amber warning whenever the increment ≠ 60: every price below is
  charged per started N minutes, NOT per hour.
- Per-row "= X / orë" equivalence next to each ladder/flat price when
  the tick isn't an hour — the multiplication nobody should do mentally.
- Example defaults are currency-scaled: ALL gets 200/100 ladder, 200/500
  up-to, 2000 lost ticket (the old "2.00/1.00" euro-scale examples read
  as 2 lekë/hour); EUR/USD keep 2/1/5/20. Threaded through empty forms,
  new tier rows, and mode-switch templates alike.

Band DURATIONS stay in hours — real wall time, increment-independent.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00

662 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useTranslation } from "react-i18next";
import { currencyOptions } from "./lib/currencies.js";
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);
/** Currency-plausible EXAMPLE amounts for fresh forms/rows. The old hardcoded
* "2.00 / 1.00" examples were euro-scaled — displayed under ALL they read as
* 2 lekë/hour, i.e. nonsense (operator feedback 2026-07-06). Lek amounts are
* ~100× the euro ones; USD rides with EUR. */
function examples(currency: string): { hi: string; lo: string; stepSmall: string; stepBig: string; lost: string } {
return currency.trim().toUpperCase() === "ALL"
? { hi: "200.00", lo: "100.00", stepSmall: "200.00", stepBig: "500.00", lost: "2000.00" }
: { hi: "2.00", lo: "1.00", stepSmall: "2.00", stepBig: "5.00", lost: "20.00" };
}
function emptySteps(currency: string): StepForm[] {
const ex = examples(currency);
return [
{ hours: "1", total: ex.stepSmall },
{ hours: "3", total: ex.stepBig },
];
}
function emptyLadder(currency: string): PricingForm {
const ex = examples(currency);
return {
mode: "ladder",
flat: "0.00",
packageTotal: "0.00",
dailyCap: "",
blocks: [{ hours: "1", price: ex.hi }, { hours: "", price: ex.lo }],
steps: emptySteps(currency),
};
}
function emptyTier(currency: string): TierForm {
return {
name: "",
priority: "10",
category: "",
dow: [],
fromHour: "",
toHour: "",
dateFrom: "",
dateTo: "",
pricing: { ...emptyLadder(currency), blocks: [{ hours: "", price: examples(currency).lo }] },
};
}
export function emptyForm(): FormState {
const currency = "ALL"; // the site's currency — examples scale with it
return {
currency,
gracePeriodEntryMin: "15",
incrementMin: "60",
lostTicket: examples(currency).lost,
gracePeriodExitMin: "15",
base: emptyLadder(currency),
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;
},
currency: string,
): PricingForm {
if (c.steps != null && c.steps.length > 0) {
return { ...emptyLadder(currency), mode: "stepped", steps: stepsToForm(c.steps) };
}
if (c.packageMinor != null) {
return { ...emptyLadder(currency), mode: "package", packageTotal: toMajor(c.packageMinor) };
}
if (c.flatMinor != null) {
return { ...emptyLadder(currency), mode: "flat", flat: toMajor(c.flatMinor) };
}
return {
...emptyLadder(currency),
mode: "ladder",
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
blocks: blocksToForm(c.blocks ?? []),
};
}
function tierFromCard(c: TariffCard, currency: string): 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, currency),
};
}
/** 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, currency),
tiers: (st.windowedCards ?? []).map((c) => tierFromCard(c, currency)),
};
}
// V1: the bare ladder becomes the default card body; no tiers.
return { ...common, base: pricingFromCard(st, currency), 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();
// The billing unit all flat/ladder prices are entered in (labels reflect it live).
const inc = Math.max(1, Math.round(Number(form.incrementMin)) || 60);
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(f.currency)] }));
}
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>
<select className="input w-24" value={form.currency} onChange={(e) => set("currency", e.target.value)}>
{currencyOptions(form.currency).map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<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 increment is the UNIT every flat/ladder price is charged in. At 60 the
form reads naturally as per-hour; any other value silently redefines every
price below, so shout it (the 60→10 "six charges per hour" trap). */}
{inc !== 60 && (
<p className="mt-2 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[0.75rem] text-term-amber">
{t("tariff.incrementWarning", { min: inc })}
</p>
)}
{/* 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}
incrementMin={inc}
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}
incrementMin={inc}
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, opts?: Record<string, unknown>) => string;
pricing: PricingForm;
/** Current billing increment (minutes) — every flat/ladder price is PER this unit,
* so the price labels state it explicitly instead of a vague "per increment". */
incrementMin: number;
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;
/** "= N / orë" equivalence for a per-increment price (only shown when the tick
* isn't an hour — at 60 the price already IS the hourly price). */
const perHour = (major: string): string | null => {
if (props.incrementMin === 60) return null;
const v = Number(major);
if (!Number.isFinite(v) || v <= 0) return null;
return t("tariff.perHourEquiv", { amount: ((v * 60) / props.incrementMin).toFixed(2) });
};
const unitLabel =
props.incrementMin === 60 ? t("tariff.pricePerHour") : t("tariff.pricePerN", { min: props.incrementMin });
const flatLabel =
props.incrementMin === 60 ? t("tariff.modeFlat") : t("tariff.modeFlatN", { min: props.incrementMin });
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")} />
{flatLabel}
</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">{unitLabel}</span>
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
{perHour(p.flat) && <span className="text-[0.6875rem] text-term-muted">{perHour(p.flat)}</span>}
</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">{unitLabel}</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">
<span className="inline-flex items-center gap-2">
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
{perHour(b.price) && <span className="text-[0.6875rem] text-term-muted">{perHour(b.price)}</span>}
</span>
</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>
);
}