feat(tariff): V2 — legacy-parity pricing (time-of-day, category, seasonal, flat)

Bring the legacy ParkSQL2017 pricing BREADTH onto our engine while keeping
integer-minor-unit money + immutable signed versions (rejecting legacy's
float money / mutable rows). TariffStructure becomes a discriminated union:
V1 = the original bare ladder (UNCHANGED, verbatim algorithm, golden-
regression-tested against the live version); V2 = {version:2, tz, shared
knobs, defaultCard, windowedCards[]} where each card is flat OR a block
ladder and may be scoped by wall-clock hour window / day-of-week / date
range / vehicle category.

computeFeeV2 prices by stepping one increment at a time, advancing the
ladder by ELAPSED minutes (continuous) while selecting the active card by
WALL-CLOCK time in the version's FROZEN tz. Decisions: tz is a per-site
setting (site_config.timezone, default Europe/Tirane) stamped server-side
into each version on publish — never the host clock (reproducibility);
default-card cap governs a mixed day; precedence = specificity
(date>dow>hour) -> priority -> name (total, order-independent), validation
rejects ambiguous ties; category = a card FIELD, frozen in the signed
vehicle_entry payload (site_config.default_vehicle_category default), read
at both pricing call-sites.

Composer: default card front-and-centre (flat/ladder toggle), tiers under
an "Advanced" disclosure; emits BARE V1 when no tiers (back-compat). DB:
migrations 0005 (timezone) + 0006 (default_vehicle_category). Stood up
vitest in @parking/shared (was zero tests on the ledger-feeding fee fn);
36 tests incl. golden V1 regression, happy-hour/overnight/dow/flat/category/
cap edges, precedence shuffle-invariance, Europe/Tirane DST determinism,
validation matrix — all green. No event-chain change.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-18 20:00:13 +02:00
parent 91cc79b14e
commit cf1ff5676d
21 changed files with 1524 additions and 163 deletions
+324 -94
View File
@@ -3,8 +3,10 @@ import { useTranslation } from "react-i18next";
import {
ApiError,
fetchTariff,
isTariffV2,
publishTariffVersion,
type TariffBlock,
type TariffCard,
type TariffStructure,
type TariffState,
} from "./api.js";
@@ -24,37 +26,63 @@ interface BlockForm {
hours: string; // duration of THIS band, in hours (ignored for the last block)
price: string; // major units, e.g. "2.00"
}
// A pricing body the form edits: either a flat rate or a block ladder.
interface PricingForm {
mode: "ladder" | "flat";
flat: string; // major units (used when mode==="flat")
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
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;
dailyCap: string; // "" = no cap
lostTicket: string;
gracePeriodExitMin: string;
blocks: BlockForm[];
// 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 emptyLadder(): PricingForm {
return { mode: "ladder", flat: "0.00", dailyCap: "", blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }] };
}
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",
dailyCap: "",
lostTicket: "20.00",
gracePeriodExitMin: "15",
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
base: emptyLadder(),
tiers: [],
};
}
// Convert a published structure's cumulative `uptoMin` (minutes) back into the
// per-band hours the form edits. Each band's hours = (its bound − previous bound)
// / 60; the open-ended last band has no hours. Legacy versions whose last block is
// bounded (pre-2026-06-18, before open-ended was required) still load: the bounded
// tail simply shows as its own band and the operator adds/keeps an open-ended one.
function blocksToForm(blocks: TariffStructure["blocks"]): BlockForm[] {
// 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) };
@@ -64,41 +92,112 @@ function blocksToForm(blocks: TariffStructure["blocks"]): BlockForm[] {
});
}
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat or ladder).
function pricingFromCard(c: { flatMinor?: number; blocks?: TariffBlock[]; dailyCapMinor?: number | null }): PricingForm {
if (c.flatMinor != null) {
return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks };
}
return {
mode: "ladder",
flat: "0.00",
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
blocks: blocksToForm(c.blocks ?? []),
};
}
function tierFromCard(c: TariffCard): TierForm {
const w = c.window ?? {};
return {
name: c.name,
priority: String(c.priority),
category: c.category ?? "",
dow: w.dow ? [...w.dow] : [],
fromHour: w.fromHour ?? "",
toHour: w.toHour ?? "",
dateFrom: w.dateFrom ?? "",
dateTo: w.dateTo ?? "",
pricing: pricingFromCard(c),
};
}
function formFromActive(s: TariffState): FormState {
const v = s.active;
if (!v) return emptyForm();
const st = v.structure;
return {
const common = {
currency: v.currency,
gracePeriodEntryMin: String(st.gracePeriodEntryMin),
incrementMin: String(st.incrementMin),
dailyCap: st.dailyCapMinor == null ? "" : toMajor(st.dailyCapMinor),
lostTicket: toMajor(st.lostTicketMinor),
gracePeriodExitMin: String(st.gracePeriodExitMin),
blocks: blocksToForm(st.blocks),
};
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) from a PricingForm.
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "dailyCapMinor"> {
if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
// 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 {
// Accumulate each band's DURATION (hours) into the engine's cumulative `uptoMin`
// (minutes). The LAST band is always open-ended (uptoMin null) — its hours are
// ignored — so the published structure always satisfies the "last block must be
// open-ended" rule (the thereafter-rate is explicit). See wiki/concepts/tariff.md.
const last = f.blocks.length - 1;
let cumulativeMin = 0;
const blocks: TariffBlock[] = f.blocks.map((b, i) => {
if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
cumulativeMin += Math.round(Number(b.hours || "0") * 60);
return { uptoMin: cumulativeMin, priceMinorPerIncrement: toMinor(b.price) };
});
return {
const common = {
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
incrementMin: Math.round(Number(f.incrementMin)),
blocks,
dailyCapMinor: f.dailyCap.trim() === "" ? null : toMinor(f.dailyCap),
lostTicketMinor: toMinor(f.lostTicket),
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
overstay: "reprice",
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 === "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),
};
}
@@ -121,27 +220,49 @@ export function TariffComposer() {
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((f) => ({ ...f, [key]: value }));
}
function setBlock(i: number, patch: Partial<BlockForm>) {
setForm((f) => ({ ...f, blocks: f.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
}
// Insert a new bounded band just BEFORE the open-ended "thereafter" tail, so the
// last block always stays open-ended.
function addBlock() {
// --- 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) => {
const tailIdx = f.blocks.length - 1;
const next = [...f.blocks];
next.splice(tailIdx, 0, { hours: "1", price: "0.00" });
return { ...f, blocks: next };
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)) };
});
}
// Remove a bounded band. The open-ended tail (last row) can't be removed (it's the
// required thereafter-rate); the guard also keeps at least the tail present.
function removeBlock(i: number) {
setForm((f) => {
if (i === f.blocks.length - 1 || f.blocks.length <= 1) return f;
return { ...f, blocks: f.blocks.filter((_, j) => j !== i) };
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) }));
}
// --- 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);
@@ -183,62 +304,92 @@ export function TariffComposer() {
<input value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
<label>{t("tariff.billingIncrement")}</label>
<input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
<label>{t("tariff.dailyCap")}</label>
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder={t("tariff.dailyCapPh")} />
<label>{t("tariff.lostTicketFee")}</label>
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
<label>{t("tariff.exitGrace")}</label>
<input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
</div>
<h3 style={{ marginBottom: "0.25rem" }}>{t("tariff.rateBlocks")}</h3>
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>{t("tariff.rateBlocksHint")}</p>
<table style={{ borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "#555" }}>
<th style={{ padding: "0 0.5rem" }}>{t("tariff.bandDuration")}</th>
<th style={{ padding: "0 0.5rem" }}>{t("tariff.pricePerIncrement")}</th>
<th />
</tr>
</thead>
<tbody>
{form.blocks.map((b, i) => {
const isTail = i === form.blocks.length - 1;
return (
<tr key={i}>
<td style={{ padding: "0.15rem 0.5rem" }}>
{isTail ? (
<span style={{ color: "#777", fontStyle: "italic" }}>{t("tariff.thereafter")}</span>
) : (
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.3rem" }}>
<input
value={b.hours}
onChange={(e) => setBlock(i, { hours: e.target.value })}
placeholder={t("tariff.egHours")}
style={{ width: 70 }}
/>
<span style={{ color: "#777" }}>{t("tariff.hoursUnit")}</span>
</span>
)}
</td>
<td style={{ padding: "0.15rem 0.5rem" }}>
<input value={b.price} onChange={(e) => setBlock(i, { price: e.target.value })} style={{ width: 90 }} />
</td>
<td>
{!isTail && (
<button type="button" onClick={() => removeBlock(i)}>
{t("tariff.remove")}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
{t("tariff.addBlock")}
</button>
{/* 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 style={{ marginBottom: "0.25rem" }}>{t("tariff.defaultCard")}</h3>
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>{t("tariff.defaultCardHint")}</p>
<PricingEditor
t={t}
pricing={form.base}
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)}
/>
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
<details style={{ marginTop: "1.25rem" }} open={form.tiers.length > 0}>
<summary style={{ cursor: "pointer", fontWeight: 600 }}>{t("tariff.tiersAdvanced")}</summary>
<p style={{ color: "#777", margin: "0.4rem 0", fontSize: "0.9em" }}>{t("tariff.tiersHint")}</p>
{form.tiers.map((tr, i) => (
<fieldset key={i} style={{ border: "1px solid #ddd", borderRadius: 6, padding: "0.6rem 0.8rem", marginBottom: "0.75rem" }}>
<legend style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<input
value={tr.name}
onChange={(e) => setTier(i, { name: e.target.value })}
placeholder={t("tariff.tierName")}
style={{ width: 140 }}
/>
<button type="button" onClick={() => removeTier(i)}>
{t("tariff.remove")}
</button>
</legend>
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.35rem 0.75rem", alignItems: "center", maxWidth: 520 }}>
<label>{t("tariff.tierPriority")}</label>
<input value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} style={{ width: 70 }} />
<label>{t("tariff.tierCategory")}</label>
<input value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} style={{ width: 140 }} />
<label>{t("tariff.tierDays")}</label>
<span style={{ display: "flex", gap: "0.3rem", flexWrap: "wrap" }}>
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
<label key={d} style={{ display: "inline-flex", alignItems: "center", gap: "0.15rem", fontSize: "0.85em" }}>
<input type="checkbox" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
{t(`tariff.dow${d}`)}
</label>
))}
</span>
<label>{t("tariff.tierHours")}</label>
<span style={{ display: "inline-flex", gap: "0.3rem", alignItems: "center" }}>
<input value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" style={{ width: 70 }} />
<span>–</span>
<input value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" style={{ width: 70 }} />
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
<span style={{ color: "#777", fontSize: "0.8em" }}>{t("tariff.tierOvernight")}</span>
)}
</span>
<label>{t("tariff.tierDates")}</label>
<span style={{ display: "inline-flex", gap: "0.3rem", alignItems: "center" }}>
<input type="date" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
<span>–</span>
<input type="date" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
</span>
</div>
<div style={{ marginTop: "0.5rem" }}>
<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" onClick={addTier}>
{t("tariff.addTier")}
</button>
</details>
<div style={{ marginTop: "1rem" }}>
<button type="button" onClick={publish} disabled={saving}>
@@ -251,3 +402,82 @@ export function TariffComposer() {
</section>
);
}
// A reusable flat/ladder pricing-body editor — used by the default card and each tier.
function PricingEditor(props: {
t: (k: string) => string;
pricing: PricingForm;
onMode: (m: "ladder" | "flat") => void;
onFlat: (v: string) => void;
onCap: (v: string) => void;
onBlock: (i: number, patch: Partial<BlockForm>) => void;
onAddBlock: () => void;
onRemoveBlock: (i: number) => void;
}) {
const { t, pricing: p } = props;
return (
<div>
<div style={{ display: "flex", gap: "1rem", marginBottom: "0.4rem", fontSize: "0.9em" }}>
<label style={{ display: "inline-flex", gap: "0.25rem", alignItems: "center" }}>
<input type="radio" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
{t("tariff.modeLadder")}
</label>
<label style={{ display: "inline-flex", gap: "0.25rem", alignItems: "center" }}>
<input type="radio" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
{t("tariff.modeFlat")}
</label>
</div>
{p.mode === "flat" ? (
<div style={{ display: "inline-flex", gap: "0.4rem", alignItems: "center" }}>
<span style={{ color: "#777" }}>{t("tariff.pricePerIncrement")}</span>
<input value={p.flat} onChange={(e) => props.onFlat(e.target.value)} style={{ width: 90 }} />
</div>
) : (
<>
<table style={{ borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "#555" }}>
<th style={{ padding: "0 0.5rem" }}>{t("tariff.bandDuration")}</th>
<th style={{ padding: "0 0.5rem" }}>{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 style={{ padding: "0.15rem 0.5rem" }}>
{isTail ? (
<span style={{ color: "#777", fontStyle: "italic" }}>{t("tariff.thereafter")}</span>
) : (
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.3rem" }}>
<input value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} style={{ width: 70 }} />
<span style={{ color: "#777" }}>{t("tariff.hoursUnit")}</span>
</span>
)}
</td>
<td style={{ padding: "0.15rem 0.5rem" }}>
<input value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} style={{ width: 90 }} />
</td>
<td>{!isTail && <button type="button" onClick={() => props.onRemoveBlock(i)}>{t("tariff.remove")}</button>}</td>
</tr>
);
})}
</tbody>
</table>
<div style={{ marginTop: "0.4rem", display: "flex", gap: "1rem", alignItems: "center" }}>
<button type="button" onClick={props.onAddBlock}>
{t("tariff.addBlock")}
</button>
<span style={{ display: "inline-flex", gap: "0.3rem", alignItems: "center", fontSize: "0.9em" }}>
<span style={{ color: "#777" }}>{t("tariff.dailyCap")}</span>
<input value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} style={{ width: 90 }} />
</span>
</div>
</>
)}
</div>
);
}
+42 -1
View File
@@ -240,7 +240,10 @@ export interface TariffBlock {
uptoMin: number | null;
priceMinorPerIncrement: number;
}
export interface TariffStructure {
// Mirrors @parking/shared. Two shapes: V1 (bare ladder) and V2 (default + windowed
// cards by time-of-day / dow / date / category, flat or laddered). The discriminant
// is the presence of `defaultCard`. See wiki/concepts/tariff-time-tiers.md.
export interface TariffStructureV1 {
gracePeriodEntryMin: number;
incrementMin: number;
blocks: TariffBlock[];
@@ -249,6 +252,39 @@ export interface TariffStructure {
gracePeriodExitMin: number;
overstay: "reprice";
}
export interface TariffWindow {
dow?: number[];
dateFrom?: string;
dateTo?: string;
fromHour?: string;
toHour?: string;
}
export interface TariffCard {
name: string;
priority: number;
category?: string;
window?: TariffWindow;
flatMinor?: number;
blocks?: TariffBlock[];
dailyCapMinor?: number | null;
}
export interface TariffStructureV2 {
version: 2;
tz: string;
gracePeriodEntryMin: number;
incrementMin: number;
lostTicketMinor: number;
gracePeriodExitMin: number;
overstay: "reprice";
defaultCard: TariffCard;
windowedCards?: TariffCard[];
}
export type TariffStructure = TariffStructureV1 | TariffStructureV2;
/** True when a structure is the windowed V2 shape (mirrors @parking/shared isTariffV2). */
export function isTariffV2(t: TariffStructure): t is TariffStructureV2 {
return (t as TariffStructureV2).defaultCard != null;
}
export interface TariffVersion {
id: string;
tariffId: string;
@@ -446,6 +482,11 @@ export interface SiteConfig {
address: string | null;
phone: string | null;
email: string | null;
/** IANA timezone for tariff wall-clock windows (e.g. "Europe/Tirane"). Copied into
* each published tariff version so its windows are frozen. */
timezone: string | null;
/** Default vehicle/customer category frozen onto each transient entry (V2 pricing). */
defaultVehicleCategory: string | null;
}
export function fetchOccupancy(): Promise<Occupancy> {
+22
View File
@@ -120,6 +120,28 @@ export const en: Catalog = {
publishNewVersion: "Publish new version",
publishing: "Publishing…",
publishedOk: "New tariff version published — it's now the active rate card.",
defaultCard: "Default card (always active)",
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
modeLadder: "Hourly ladder",
modeFlat: "Flat price",
tiersAdvanced: "Advanced: time & seasonal tiers",
tiersHint: "Optional. Add cards that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, the simple card is published.",
tierName: "Name",
tierPriority: "Priority",
tierCategory: "Category",
tierCategoryPh: "e.g. bus",
tierDays: "Days",
tierHours: "Hours",
tierDates: "Dates",
tierOvernight: "(crosses midnight)",
addTier: "+ Add tier",
dow1: "Mon",
dow2: "Tue",
dow3: "Wed",
dow4: "Thu",
dow5: "Fri",
dow6: "Sat",
dow0: "Sun",
},
subs: {
title: "Subscriptions",
+22
View File
@@ -122,6 +122,28 @@ export const sq = {
publishNewVersion: "Publiko version të ri",
publishing: "Duke publikuar…",
publishedOk: "U publikua versioni i ri i tarifës — tani është karta tarifore aktive.",
defaultCard: "Karta e parazgjedhur (gjithmonë aktive)",
defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.",
modeLadder: "Shkallë orësh",
modeFlat: "Çmim fiks",
tiersAdvanced: "Të avancuara: nivele kohore & sezonale",
tiersHint: "Opsionale. Shto karta që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet karta e thjeshtë.",
tierName: "Emri",
tierPriority: "Përparësia",
tierCategory: "Kategoria",
tierCategoryPh: "p.sh. autobus",
tierDays: "Ditët",
tierHours: "Orët",
tierDates: "Datat",
tierOvernight: "(kalon mesnatën)",
addTier: "+ Shto nivel",
dow1: "Hën",
dow2: "Mar",
dow3: "Mër",
dow4: "Enj",
dow5: "Pre",
dow6: "Sht",
dow0: "Die",
},
subs: {
title: "Abonimet",