9a1feeeb20
A stepped ("up-to") default card prices the whole stay as one total, so the V2
engine short-circuits to steppedFee and NEVER consults windowed cards — any
time/seasonal tiers would silently never fire. Found live: an active tariff had a
stepped base plus weekday-night + weekend tiers, and every 3h stay priced 600 ALL
regardless of hour/day because the tiers were dead.
- validateTariffV2 now rejects a stepped defaultCard combined with windowedCards,
with an actionable message (switch the base to ladder/flat, or remove the tiers).
- Composer shows an inline red warning the moment base mode is stepped and tiers
exist; publishing is blocked server-side regardless.
- ApiError now carries the server's problems[], so the publish error surfaces the
SPECIFIC reason instead of a generic "invalid tariff structure".
- 2 new validation tests (55 pass).
Wiki: tariff, log.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
623 lines
26 KiB
TypeScript
623 lines
26 KiB
TypeScript
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<TariffCard, "flatMinor" | "blocks" | "steps" | "dailyCapMinor"> {
|
||
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<TariffState | null>(null);
|
||
const [form, setForm] = useState<FormState>(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<K extends keyof FormState>(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<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>) {
|
||
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 (
|
||
<section className="mx-auto max-w-3xl 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-[12px] text-term-amber">
|
||
{t("tariff.noRateCard")}
|
||
</p>
|
||
) : (
|
||
<p className="mb-4 text-[12px] text-term-muted">
|
||
{t("tariff.activeSince", {
|
||
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
||
count: state.versions.length,
|
||
})}
|
||
</p>
|
||
)}
|
||
|
||
<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-[12px] 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-[12px] 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-[11px] 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}
|
||
onMode={(mode) => 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)}
|
||
/>
|
||
</div>
|
||
</fieldset>
|
||
))}
|
||
<button type="button" className="btn btn-sm" onClick={addTier}>
|
||
{t("tariff.addTier")}
|
||
</button>
|
||
</details>
|
||
|
||
<div className="mt-6 flex items-center gap-3">
|
||
<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-[12px] text-term-green" : "text-[12px] text-term-red"}>{msg.text}</span>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// 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<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 gap-4 text-[12px]">
|
||
<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>
|
||
)}
|
||
</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-[11px] 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-[11px] 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>
|
||
);
|
||
}
|