ab968eb25e
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
662 lines
29 KiB
TypeScript
662 lines
29 KiB
TypeScript
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>
|
||
);
|
||
}
|