feat(tariff-lab): DB-backed draft tariffs + named published versions

Experimenting used to mean publishing — churning the immutable version
history and risking real tickets pricing against a half-baked card while
the admin iterated. The lab is now a true sandbox:

- tariff_drafts table (migration 0021): MUTABLE by design — the one
  exception to "editing publishes a version"; a draft prices nothing and
  signs nothing. Drafts are validated + tz-stamped on save exactly like a
  publish, so a saved draft always simulates and never fails at publish.
- CRUD under /api/tariff/drafts (list tariff:read, mutations
  tariff:update); publishing a draft goes through the normal immutable
  POST /api/tariff/versions path.
- Lab UI rebuilt: sidebar lists lab drafts AND the full published history
  (click any to price against it); main pane cut to pure entry/exit
  (ticket loader, payment, category inputs dropped); the composer form is
  extracted to TariffEditorForm.tsx and reused in a modal (new drafts
  prefill from the active card); per-draft Publish with confirm.
- tariff_versions.name (migration 0022): optional label stamped at
  publish — carried from the lab draft, or typed in the composer's new
  optional field — so history reads "Winter 2027", not UUID prefixes.
- Includes the composer UI + sq/en labels for the package mode (engine
  landed in d9e6c13) and the "Flat price / hour" relabel.

5 new server integration tests (RBAC, roundtrip, validation, tz-stamp +
simulate + publish w/ name); server suite 288 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-07-05 14:31:42 +02:00
parent 52a89bfa56
commit fd9885e9ec
14 changed files with 1457 additions and 782 deletions
@@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js";
import { seedUser, login } from "../test-helpers.js";
// Tariff-lab drafts: the MUTABLE experiment scratchpad next to the immutable
// published versions. The contract under test: drafts are validated + tz-stamped on
// save exactly like a publish (so "publish this draft" can never fail on a card that
// saved fine), mutations need tariff:update, and publishing a draft goes through the
// normal immutable-version path untouched.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
const V1_STRUCTURE = {
gracePeriodEntryMin: 5,
incrementMin: 60,
lostTicketMinor: 2000,
gracePeriodExitMin: 10,
overstay: "reprice",
blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }],
dailyCapMinor: null,
};
// A V2 card with a night package — tz left blank on purpose: the server must stamp it.
const V2_STRUCTURE = {
version: 2,
tz: "",
gracePeriodEntryMin: 5,
incrementMin: 60,
lostTicketMinor: 2000,
gracePeriodExitMin: 10,
overstay: "reprice",
defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }], dailyCapMinor: null },
windowedCards: [{ name: "night", priority: 10, window: { fromHour: "20:00", toHour: "07:00" }, packageMinor: 40000 }],
};
async function editor() {
const { username, password } = await seedUser(db, {
username: "editor",
roleId: "editor",
permissions: ["tariff:read", "tariff:update"],
});
return login(app, username, password);
}
describe("tariff drafts", () => {
it("requires auth", async () => {
const res = await app.inject({ method: "GET", url: "/api/tariff/drafts" });
expect(res.statusCode).toBe(401);
});
it("a tariff:read-only user can list but not create", async () => {
const { username, password } = await seedUser(db, {
username: "viewer",
roleId: "viewer",
permissions: ["tariff:read"],
});
const { cookie, csrf } = await login(app, username, password);
const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
expect(list.statusCode).toBe(200);
expect(list.json().drafts).toEqual([]);
const create = await app.inject({
method: "POST",
url: "/api/tariff/drafts",
headers: { cookie, "x-csrf-token": csrf },
payload: { name: "x", currency: "ALL", structure: V1_STRUCTURE },
});
expect(create.statusCode).toBe(403);
});
it("create → list → update → delete roundtrip", async () => {
const { cookie, csrf } = await editor();
const headers = { cookie, "x-csrf-token": csrf };
const create = await app.inject({
method: "POST",
url: "/api/tariff/drafts",
headers,
payload: { name: "Winter proposal", currency: "all", structure: V1_STRUCTURE },
});
expect(create.statusCode).toBe(201);
const draft = create.json();
expect(draft.name).toBe("Winter proposal");
expect(draft.currency).toBe("ALL"); // normalised to upper case
expect(draft.createdBy).toBe("editor");
const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
expect(list.json().drafts).toHaveLength(1);
const update = await app.inject({
method: "PUT",
url: `/api/tariff/drafts/${draft.id}`,
headers,
payload: { name: "Winter v2", currency: "ALL", structure: V1_STRUCTURE },
});
expect(update.statusCode).toBe(200);
expect(update.json().name).toBe("Winter v2");
const del = await app.inject({ method: "DELETE", url: `/api/tariff/drafts/${draft.id}`, headers });
expect(del.statusCode).toBe(204);
const after = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
expect(after.json().drafts).toEqual([]);
});
it("rejects an invalid structure with problems (validated like a publish)", async () => {
const { cookie, csrf } = await editor();
const res = await app.inject({
method: "POST",
url: "/api/tariff/drafts",
headers: { cookie, "x-csrf-token": csrf },
payload: { name: "broken", currency: "ALL", structure: { ...V1_STRUCTURE, blocks: [] } },
});
expect(res.statusCode).toBe(400);
expect(res.json().problems?.length).toBeGreaterThan(0);
});
it("stamps the site timezone on a V2 draft, and the draft simulates + publishes as-is", async () => {
const { cookie, csrf } = await editor();
const headers = { cookie, "x-csrf-token": csrf };
const create = await app.inject({
method: "POST",
url: "/api/tariff/drafts",
headers,
payload: { name: "Night package", currency: "ALL", structure: V2_STRUCTURE },
});
expect(create.statusCode).toBe(201);
const draft = create.json();
expect(draft.structure.tz).toBe("Europe/Tirane");
// The lab prices the draft by sending its stored structure inline.
const sim = await app.inject({
method: "POST",
url: "/api/tariff/simulate",
headers,
payload: {
enteredAt: "2026-07-03T21:00:00.000+02:00",
asOf: "2026-07-03T23:00:00.000+02:00",
structure: draft.structure,
currency: draft.currency,
},
});
expect(sim.statusCode).toBe(200);
expect(sim.json().pricing.amountMinor).toBe(40000); // one night package
// "Publish this draft" = the normal immutable-version path with the draft's card;
// the draft's name rides along as the version's optional label.
const publish = await app.inject({
method: "POST",
url: "/api/tariff/versions",
headers,
payload: { currency: draft.currency, structure: draft.structure, name: draft.name },
});
expect(publish.statusCode).toBe(201);
const state = await app.inject({ method: "GET", url: "/api/tariff", headers: { cookie } });
expect(state.json().active?.name).toBe("Night package");
expect(state.json().active?.structure?.windowedCards?.[0]?.packageMinor).toBe(40000);
});
});
+99 -8
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db";
import {
computeFee,
isTariffV2,
@@ -25,10 +25,19 @@ interface PublishBody {
structure: TariffStructure;
/** When this version takes effect (ISO-8601). Defaults to now. */
effectiveFrom?: string;
/** Optional human label (e.g. carried from the lab draft being published). */
name?: string;
}
const SITE_TARIFF_NAME = "Site tariff";
/** Body for saving a lab draft (create + update share the shape). */
interface DraftBody {
name: string;
currency: string;
structure: TariffStructure;
}
/** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write.
* Provide a structure source (one of): `tariffVersionId`, inline `structure`, or
* neither (uses the active version). */
@@ -80,19 +89,14 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
"/api/tariff/versions",
{ preHandler: writeGuard },
async (req, reply) => {
const { currency, structure, effectiveFrom } = req.body ?? ({} as PublishBody);
const { currency, structure, effectiveFrom, name } = req.body ?? ({} as PublishBody);
if (!currency || typeof currency !== "string" || currency.length < 3) {
return reply.code(400).send({ error: "currency (ISO 4217) required" });
}
// For a windowed (V2) structure, stamp the wall-clock timezone from SITE config
// (not the client) BEFORE validating — so the frozen tz is authoritative and the
// validation that requires tz passes. A V1 (bare) structure is left untouched.
let toStore: TariffStructure = structure;
if (structure && isTariffV2(structure)) {
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
toStore = { ...structure, tz };
}
const toStore = stampSiteTz(structure);
const problems = validateTariffStructure(toStore);
if (problems.length) {
@@ -128,6 +132,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
const row = {
id,
tariffId,
name: typeof name === "string" && name.trim() ? name.trim() : null,
effectiveFrom: effective,
currency,
structure: toStore as unknown as Record<string, unknown>,
@@ -230,6 +235,92 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
},
);
// --- Lab drafts ---------------------------------------------------------------
// The lab's scratchpad: MUTABLE experimental rate cards (see tariff_drafts in the
// schema for why mutability is safe here — a draft prices nothing and signs
// nothing). Saved drafts are validated + tz-stamped exactly like a publish, so the
// simulator can always price them and "publish this draft" can never surprise the
// admin with a card that saved fine but won't go live. Publishing a draft is just
// POST /api/tariff/versions with the draft's structure — same guard, same
// validation, same immutability.
app.get("/api/tariff/drafts", { preHandler: readGuard }, async () => {
const drafts = db.select().from(tariffDrafts).orderBy(desc(tariffDrafts.updatedAt)).all();
return { drafts };
});
app.post<{ Body: DraftBody }>("/api/tariff/drafts", { preHandler: writeGuard }, async (req, reply) => {
const parsed = parseDraftBody(req.body);
if ("error" in parsed) return reply.code(400).send(parsed);
const now = new Date().toISOString();
const row = {
id: randomUUID(),
name: parsed.name,
currency: parsed.currency,
structure: parsed.structure as unknown as Record<string, unknown>,
createdBy: req.user?.username ?? null,
createdAt: now,
updatedAt: now,
};
db.insert(tariffDrafts).values(row).run();
return reply.code(201).send(row);
});
app.put<{ Params: { id: string }; Body: DraftBody }>(
"/api/tariff/drafts/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get();
if (!existing) return reply.code(404).send({ error: "draft not found" });
const parsed = parseDraftBody(req.body);
if ("error" in parsed) return reply.code(400).send(parsed);
const patch = {
name: parsed.name,
currency: parsed.currency,
structure: parsed.structure as unknown as Record<string, unknown>,
updatedAt: new Date().toISOString(),
};
db.update(tariffDrafts).set(patch).where(eq(tariffDrafts.id, existing.id)).run();
return { ...existing, ...patch };
},
);
app.delete<{ Params: { id: string } }>(
"/api/tariff/drafts/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get();
if (!existing) return reply.code(404).send({ error: "draft not found" });
db.delete(tariffDrafts).where(eq(tariffDrafts.id, existing.id)).run();
return reply.code(204).send();
},
);
/** Validate + normalise a draft save body; tz-stamps V2 structures like a publish. */
function parseDraftBody(
body: DraftBody | undefined,
): { name: string; currency: string; structure: TariffStructure } | { error: string; problems?: string[] } {
const b = body ?? ({} as DraftBody);
const name = (b.name ?? "").trim();
if (!name) return { error: "name required" };
const currency = (b.currency ?? "").trim().toUpperCase();
if (currency.length < 3) return { error: "currency (ISO 4217) required" };
const structure = stampSiteTz(b.structure);
const problems = validateTariffStructure(structure);
if (problems.length) return { error: "invalid tariff structure", problems };
return { name, currency, structure };
}
/** Stamp a V2 structure's frozen wall-clock timezone from SITE config (never the
* client); a V1 (bare) structure passes through untouched. */
function stampSiteTz(structure: TariffStructure): TariffStructure {
if (structure && isTariffV2(structure)) {
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
return { ...structure, tz };
}
return structure;
}
/** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */
function tariffVersionIdFor(whenIso: string): string | null {
const tariffId = ensureSiteTariff();
+25 -555
View File
@@ -1,267 +1,23 @@
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";
import { ApiError, fetchTariff, publishTariffVersion, type TariffState } from "./api.js";
import { TariffEditorForm, emptyForm, formFromActive, toStructure, type FormState } from "./TariffEditorForm.js";
// Tariff composer — the admin builds + edits the rate card at runtime. Publishing
// Tariff composer — the admin edits + publishes the LIVE rate card. 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),
};
}
// sessions reprice correctly. The form machinery is shared with the Tariff Lab's
// draft modal — see TariffEditorForm.tsx. To experiment without publishing, use the
// lab (a draft only becomes real through this same publish path). See
// wiki/concepts/tariff.md.
export function TariffComposer() {
const { t } = useTranslation();
const [state, setState] = useState<TariffState | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
// Optional label for the version about to be published. Deliberately NOT prefilled
// from the active version — a tweaked card republished under last season's name
// would mislabel the history.
const [versionName, setVersionName] = useState("");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
@@ -274,70 +30,18 @@ export function TariffComposer() {
.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) });
await publishTariffVersion({
currency: form.currency.trim().toUpperCase(),
structure: toStructure(form),
...(versionName.trim() ? { name: versionName.trim() } : {}),
});
const fresh = await fetchTariff();
setState(fresh);
setVersionName("");
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
} catch (e) {
const text =
@@ -359,6 +63,7 @@ export function TariffComposer() {
</p>
) : (
<p className="mb-4 text-[0.75rem] text-term-muted">
{state.active.name ? `${state.active.name} — ` : ""}
{t("tariff.activeSince", {
date: new Date(state.active.effectiveFrom).toLocaleString(),
count: state.versions.length,
@@ -366,114 +71,15 @@ export function TariffComposer() {
</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>
<TariffEditorForm form={form} onChange={setForm} />
{/* 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 className="mt-6 flex flex-wrap items-center gap-3">
<input
className="input w-64"
value={versionName}
onChange={(e) => setVersionName(e.target.value)}
placeholder={t("tariff.versionNamePh")}
/>
</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}
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>
@@ -484,139 +90,3 @@ export function TariffComposer() {
</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-[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")} />
{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-[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">{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-[0.6875rem] 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>
);
}
+604
View File
@@ -0,0 +1,604 @@
import { useTranslation } from "react-i18next";
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);
function emptySteps(): StepForm[] {
return [
{ hours: "1", total: "2.00" },
{ hours: "3", total: "5.00" },
];
}
function emptyLadder(): PricingForm {
return {
mode: "ladder",
flat: "0.00",
packageTotal: "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" }] },
};
}
export 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, stepped,
// or window package).
function pricingFromCard(c: {
flatMinor?: number;
blocks?: TariffBlock[];
steps?: TariffStep[];
packageMinor?: number;
dailyCapMinor?: number | null;
}): PricingForm {
if (c.steps != null && c.steps.length > 0) {
return { ...emptyLadder(), mode: "stepped", steps: stepsToForm(c.steps) };
}
if (c.packageMinor != null) {
return { ...emptyLadder(), mode: "package", packageTotal: toMajor(c.packageMinor) };
}
if (c.flatMinor != null) {
return { ...emptyLadder(), mode: "flat", flat: toMajor(c.flatMinor) };
}
return {
...emptyLadder(),
mode: "ladder",
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),
};
}
/** 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), tiers: (st.windowedCards ?? []).map(tierFromCard) };
}
// V1: the bare ladder becomes the default card body; no tiers.
return { ...common, base: pricingFromCard(st), 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();
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()] }));
}
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>
<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-[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}
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) => string;
pricing: PricingForm;
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;
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")} />
{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>
)}
{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">{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-[0.6875rem] 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>
);
}
+329 -165
View File
@@ -1,21 +1,30 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ApiError,
createTariffDraft,
deleteTariffDraft,
fetchTariff,
loadSimSession,
fetchTariffDrafts,
publishTariffVersion,
simulateTariff,
updateTariffDraft,
type SimulateResult,
type SimPayment,
type TariffDraft,
type TariffState,
} from "./api.js";
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
import { Modal } from "./ui/Modal.js";
import { formatMoney, formatDuration } from "./lib/format.js";
// The TARIFF LAB — a pure session-pricing simulator. Test rates "in time" (overnight
// windows, daily caps, overstay) in seconds instead of waiting hours, against ANY
// published tariff version, with no real ledger writes. Build a hypothetical session
// (entry, optional payment, "now") OR load a real ticket and re-evaluate it at any
// instant. Prices via the SAME `priceSession` the booth uses (server), so the lab and
// the live booth can never diverge. See wiki/concepts/tariff.md, booth-exit-flow.md.
// The TARIFF LAB — a sandbox for composing + pricing EXPERIMENTAL rate cards. Drafts
// live in their own mutable table (tariff_drafts), so experimenting never churns the
// immutable published versions or risks a half-baked card going live: the admin
// composes a draft in the modal (the same form the composer page uses), simulates
// hypothetical stays against it (entry + exit, nothing else), and only when satisfied
// PUBLISHES it through the normal immutable-version path. Pricing uses the SAME
// `priceSession` the booth uses (server-side), so the lab and the live booth can
// never diverge. No ledger writes. See wiki/concepts/tariff.md.
/** <input type="datetime-local"> wants "YYYY-MM-DDTHH:mm" in LOCAL time. */
function toLocalInput(iso: string): string {
@@ -33,50 +42,76 @@ function nowLocal(): string {
return toLocalInput(new Date().toISOString());
}
/** What the simulation runs against: the live card, a historical published
* version, or one lab draft. */
type Selection = { kind: "active" } | { kind: "version"; id: string } | { kind: "draft"; id: string };
/** Modal state: a draft being composed (id null = not yet saved). */
interface DraftEdit {
id: string | null;
name: string;
form: FormState;
}
export function TariffLab() {
const { t } = useTranslation();
const [state, setState] = useState<TariffState | null>(null);
const [drafts, setDrafts] = useState<TariffDraft[]>([]);
const [selected, setSelected] = useState<Selection>({ kind: "active" });
const [err, setErr] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
// Inputs (datetime-local strings, local wall-clock).
// The hypothetical stay: entry + exit, nothing else.
const [entered, setEntered] = useState<string>(() => {
const d = new Date();
d.setHours(d.getHours() - 3); // default: a 3h-ago entry
return toLocalInput(d.toISOString());
});
const [asOf, setAsOf] = useState<string>(nowLocal);
const [category, setCategory] = useState("");
const [versionId, setVersionId] = useState<string>(""); // "" = active
// Optional single hypothetical payment (the latest grants the walk-back grace).
const [paid, setPaid] = useState(false);
const [paidAt, setPaidAt] = useState<string>(nowLocal);
const [graceMin, setGraceMin] = useState<string>("5");
// Load-a-real-ticket.
const [ticket, setTicket] = useState("");
const [loadMsg, setLoadMsg] = useState<string | null>(null);
const [exit, setExit] = useState<string>(nowLocal);
const [result, setResult] = useState<SimulateResult | null>(null);
const [busy, setBusy] = useState(false);
// The draft-composer modal.
const [edit, setEdit] = useState<DraftEdit | null>(null);
const [saving, setSaving] = useState(false);
const [editErr, setEditErr] = useState<string | null>(null);
async function refresh() {
const [s, d] = await Promise.all([fetchTariff(), fetchTariffDrafts()]);
setState(s);
setDrafts(d.drafts);
return d.drafts;
}
useEffect(() => {
fetchTariff()
.then(setState)
.catch((e) => setErr((e as Error).message));
refresh().catch((e) => setErr((e as Error).message));
}, []);
const selectedDraft = selected.kind === "draft" ? drafts.find((d) => d.id === selected.id) ?? null : null;
const selectedVersion =
selected.kind === "version" ? state?.versions.find((v) => v.id === selected.id) ?? null : null;
function select(sel: Selection) {
setSelected(sel);
setResult(null); // a stale price against another card would mislead
setErr(null);
setNotice(null);
}
async function run() {
setErr(null);
setBusy(true);
try {
const payments: SimPayment[] = paid
? [{ paidAt: fromLocalInput(paidAt), graceExitMin: graceMin.trim() === "" ? null : Number(graceMin) }]
: [];
const r = await simulateTariff({
enteredAt: fromLocalInput(entered),
asOf: fromLocalInput(asOf),
payments,
category: category.trim() || undefined,
tariffVersionId: versionId || undefined,
asOf: fromLocalInput(exit),
// A draft carries its own structure+currency; a historical version is
// referenced by id; otherwise the ACTIVE version.
...(selectedDraft
? { structure: selectedDraft.structure, currency: selectedDraft.currency }
: selectedVersion
? { tariffVersionId: selectedVersion.id }
: {}),
});
setResult(r);
} catch (e) {
@@ -87,162 +122,291 @@ export function TariffLab() {
}
}
async function loadTicket() {
setLoadMsg(null);
// --- draft actions ---
function newDraft() {
// Start from the live card when there is one — the admin usually experiments
// with a variation of today's prices, not from a blank slate.
const form = state?.active ? formFromActive(state) : emptyForm();
setEditErr(null);
setEdit({ id: null, name: "", form });
}
function editDraft(d: TariffDraft) {
setEditErr(null);
setEdit({ id: d.id, name: d.name, form: formFromVersion(d.currency, d.structure) });
}
async function saveDraft() {
if (!edit) return;
setSaving(true);
setEditErr(null);
try {
const body = {
name: edit.name.trim(),
currency: edit.form.currency.trim().toUpperCase(),
structure: toStructure(edit.form),
};
const saved = edit.id ? await updateTariffDraft(edit.id, body) : await createTariffDraft(body);
await refresh();
setEdit(null);
select({ kind: "draft", id: saved.id });
} catch (e) {
const text =
e instanceof ApiError && e.problems?.length ? `${e.message}: ${e.problems.join("; ")}` : (e as Error).message;
setEditErr(text);
} finally {
setSaving(false);
}
}
async function removeDraft(d: TariffDraft) {
if (!confirm(t("lab.confirmDelete", { name: d.name }))) return;
setErr(null);
try {
const s = await loadSimSession(ticket.trim());
setEntered(toLocalInput(s.enteredAt));
setAsOf(s.exitedAt ? toLocalInput(s.exitedAt) : nowLocal());
setCategory(s.category ?? "");
setVersionId(s.tariffVersionId ?? "");
const last = s.payments.at(-1);
if (last) {
setPaid(true);
setPaidAt(toLocalInput(last.paidAt));
setGraceMin(last.graceExitMin != null ? String(last.graceExitMin) : "");
} else {
setPaid(false);
}
setLoadMsg(t("lab.loaded", { id: s.identity }));
await deleteTariffDraft(d.id);
await refresh();
select({ kind: "active" });
} catch (e) {
setErr((e as Error).message);
}
}
const currency = result?.currency ?? state?.active?.currency ?? "ALL";
async function publishDraft(d: TariffDraft) {
if (!confirm(t("lab.confirmPublish", { name: d.name }))) return;
setErr(null);
setNotice(null);
try {
// The draft's name rides along onto the immutable version.
await publishTariffVersion({ currency: d.currency, structure: d.structure, name: d.name });
await refresh();
setNotice(t("tariff.publishedOk"));
} catch (e) {
const text =
e instanceof ApiError && e.problems?.length ? `${e.message}: ${e.problems.join("; ")}` : (e as Error).message;
setErr(text);
}
}
const currency = result?.currency ?? selectedDraft?.currency ?? selectedVersion?.currency ?? state?.active?.currency ?? "ALL";
return (
<section className="px-4 py-6">
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("lab.title")}</h2>
<p className="hint mb-4">{t("lab.intro")}</p>
{/* Load a real ticket */}
<div className="card card-body mb-4 flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1">
<label className="label">{t("lab.loadTicket")}</label>
<input
className="input w-56"
value={ticket}
onChange={(e) => setTicket(e.target.value)}
placeholder={t("lab.loadTicketPh")}
/>
</div>
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}>
{t("lab.load")}
</button>
{loadMsg && <span className="text-[0.75rem] text-term-green">{loadMsg}</span>}
</div>
<div className="flex flex-col gap-4 lg:flex-row">
{/* Main: the hypothetical stay + result, priced against the selection. */}
<div className="min-w-0 flex-1">
{/* What we're pricing against + draft actions. */}
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="rounded bg-term-panel-2 px-2 py-1 text-[0.75rem] text-term-cyan">
{selectedDraft
? selectedDraft.name
: selectedVersion
? selectedVersion.name ?? new Date(selectedVersion.effectiveFrom).toLocaleString()
: t("lab.activeTariff")}
</span>
{selectedDraft && (
<>
<button type="button" className="btn btn-sm" onClick={() => editDraft(selectedDraft)}>
{t("lab.edit")}
</button>
<button type="button" className="btn btn-sm" onClick={() => publishDraft(selectedDraft)}>
{t("lab.publish")}
</button>
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeDraft(selectedDraft)}>
{t("lab.delete")}
</button>
</>
)}
{notice && <span className="text-[0.75rem] text-term-green">{notice}</span>}
</div>
{/* Hypothetical session inputs */}
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("lab.tariffVersion")}</label>
<select className="input w-full max-w-md" value={versionId} onChange={(e) => setVersionId(e.target.value)}>
<option value="">{t("lab.activeVersion")}</option>
{state?.versions.map((v) => (
<option key={v.id} value={v.id}>
{new Date(v.effectiveFrom).toLocaleString()} · {v.currency} · {v.id.slice(0, 8)}
</option>
))}
</select>
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("lab.entered")}</label>
<input type="datetime-local" className="input w-64" value={entered} onChange={(e) => setEntered(e.target.value)} />
<label className="label">{t("lab.entered")}</label>
<input type="datetime-local" className="input w-64" value={entered} onChange={(e) => setEntered(e.target.value)} />
<label className="label">{t("lab.exit")}</label>
<span className="flex items-center gap-2">
<input type="datetime-local" className="input w-64" value={exit} onChange={(e) => setExit(e.target.value)} />
<button type="button" className="btn btn-sm" onClick={() => setExit(nowLocal())}>
{t("lab.now")}
</button>
</span>
</div>
<label className="label">{t("lab.asOf")}</label>
<span className="flex items-center gap-2">
<input type="datetime-local" className="input w-64" value={asOf} onChange={(e) => setAsOf(e.target.value)} />
<button type="button" className="btn btn-sm" onClick={() => setAsOf(nowLocal())}>
{t("lab.now")}
</button>
</span>
<div className="mt-4 flex items-center gap-3">
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
{busy ? t("lab.pricing") : t("lab.price")}
</button>
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
</div>
<label className="label">{t("lab.category")}</label>
<input
className="input w-40"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder={t("lab.categoryPh")}
/>
{result && (
<div className="mt-6 grid gap-4 md:grid-cols-2">
{/* Outcome */}
<div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[0.8125rem]">
<dt className="text-term-muted">{t("lab.amountDue")}</dt>
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
<dd className="text-term-text">
{formatDuration(result.pricing.periodStart, fromLocalInput(exit))}
{result.pricing.overstay && (
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-red">
{t("lab.overstay")}
</span>
)}
{result.pricing.withinGrace && (
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-green">
{t("lab.settled")}
</span>
)}
</dd>
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
<dd className="text-term-text">{new Date(result.pricing.periodStart).toLocaleString()}</dd>
{result.pricing.graceExpiresAt && (
<>
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
<dd className="text-term-text">{new Date(result.pricing.graceExpiresAt).toLocaleString()}</dd>
</>
)}
</dl>
</div>
<label className="label">{t("lab.payment")}</label>
<span className="flex flex-wrap items-center gap-2">
<label className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={paid} onChange={(e) => setPaid(e.target.checked)} />
{t("lab.paid")}
</label>
{paid && (
<>
<input
type="datetime-local"
className="input w-64"
value={paidAt}
onChange={(e) => setPaidAt(e.target.value)}
/>
<span className="text-term-muted">{t("lab.graceMin")}</span>
<input className="input w-20" value={graceMin} onChange={(e) => setGraceMin(e.target.value)} />
</>
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
<div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
<p className="hint mb-2">{t("lab.curveHint")}</p>
<table className="w-full text-[0.75rem] tabular-nums">
<tbody>
{result.curve.map((c) => (
<tr key={c.minutes} className="border-b border-term-border/40">
<td className="py-0.5 text-term-muted">{labelMin(c.minutes)}</td>
<td className="py-0.5 text-right text-term-text">{formatMoney(c.amountMinor, currency)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</span>
</div>
<div className="mt-4 flex items-center gap-3">
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
{busy ? t("lab.pricing") : t("lab.price")}
</button>
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
</div>
{result && (
<div className="mt-6 grid gap-4 md:grid-cols-2">
{/* Outcome */}
<div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[0.8125rem]">
<dt className="text-term-muted">{t("lab.amountDue")}</dt>
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
<dd className="text-term-text">
{formatDuration(result.pricing.periodStart, fromLocalInput(asOf))}
{result.pricing.overstay && (
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-red">
{t("lab.overstay")}
</span>
)}
{result.pricing.withinGrace && (
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-green">
{t("lab.settled")}
</span>
)}
</dd>
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
<dd className="text-term-text">{new Date(result.pricing.periodStart).toLocaleString()}</dd>
{result.pricing.graceExpiresAt && (
<>
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
<dd className="text-term-text">{new Date(result.pricing.graceExpiresAt).toLocaleString()}</dd>
</>
)}
</dl>
</div>
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
<div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
<p className="hint mb-2">{t("lab.curveHint")}</p>
<table className="w-full text-[0.75rem] tabular-nums">
<tbody>
{result.curve.map((c) => (
<tr key={c.minutes} className="border-b border-term-border/40">
<td className="py-0.5 text-term-muted">{labelMin(c.minutes)}</td>
<td className="py-0.5 text-right text-term-text">{formatMoney(c.amountMinor, currency)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Sidebar: lab drafts + the full published history; click any to price
against it. */}
<aside className="w-full shrink-0 lg:w-72">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.drafts")}</h3>
<button type="button" className="btn btn-sm" onClick={newDraft}>
{t("lab.newDraft")}
</button>
</div>
<ul className="flex flex-col gap-1">
{drafts.map((d) => (
<li key={d.id}>
<button
type="button"
onClick={() => select({ kind: "draft", id: d.id })}
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
selected.kind === "draft" && selected.id === d.id
? "border-term-amber bg-term-amber/10 text-term-text"
: "border-term-border text-term-muted hover:text-term-text"
}`}
>
<span className="block font-semibold">{d.name}</span>
<span className="block text-[0.6875rem] text-term-muted">
{d.currency} · {new Date(d.updatedAt).toLocaleString()}
</span>
</button>
</li>
))}
{drafts.length === 0 && <li className="hint px-1 py-2">{t("lab.noDrafts")}</li>}
</ul>
{/* Published versions: the active card first, then the immutable history
(older versions still price past sessions — see wiki/concepts/tariff.md). */}
<h3 className="mb-2 mt-5 text-h6 font-semibold uppercase tracking-wider text-term-text">
{t("lab.published")}
</h3>
<ul className="flex flex-col gap-1">
<li>
<button
type="button"
onClick={() => select({ kind: "active" })}
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
selected.kind === "active"
? "border-term-amber bg-term-amber/10 text-term-text"
: "border-term-border text-term-muted hover:text-term-text"
}`}
>
<span className="block font-semibold">
{t("lab.activeTariff")}
{state?.active?.name ? ` — ${state.active.name}` : ""}
</span>
<span className="block text-[0.6875rem] text-term-muted">
{state?.active ? new Date(state.active.effectiveFrom).toLocaleString() : t("tariff.noRateCard")}
</span>
</button>
</li>
{state?.versions
.filter((v) => v.id !== state.active?.id)
.map((v) => (
<li key={v.id}>
<button
type="button"
onClick={() => select({ kind: "version", id: v.id })}
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
selected.kind === "version" && selected.id === v.id
? "border-term-amber bg-term-amber/10 text-term-text"
: "border-term-border text-term-muted hover:text-term-text"
}`}
>
<span className="block font-semibold">
{v.name ?? new Date(v.effectiveFrom).toLocaleString()}
</span>
<span className="block text-[0.6875rem] text-term-muted">
{v.name ? `${new Date(v.effectiveFrom).toLocaleString()} · ` : ""}
{v.currency}
</span>
</button>
</li>
))}
</ul>
</aside>
</div>
{/* The draft composer — the SAME form the /setup/tariff page uses, in a modal. */}
<Modal
open={edit != null}
onClose={() => setEdit(null)}
title={edit?.id ? t("lab.editDraftTitle") : t("lab.newDraftTitle")}
width="max-w-3xl"
>
{edit && (
<div>
<div className="mb-4 flex items-center gap-2">
<label className="label">{t("lab.draftName")}</label>
<input
className="input w-72"
value={edit.name}
onChange={(e) => setEdit((d) => (d ? { ...d, name: e.target.value } : d))}
placeholder={t("lab.draftNamePh")}
/>
</div>
<TariffEditorForm
form={edit.form}
onChange={(update) => setEdit((d) => (d ? { ...d, form: update(d.form) } : d))}
/>
<div className="mt-6 flex items-center gap-3">
<button type="button" className="btn btn-primary" onClick={saveDraft} disabled={saving || !edit.name.trim()}>
{saving ? t("lab.savingDraft") : t("lab.saveDraft")}
</button>
{editErr && <span className="text-[0.75rem] text-term-red">{editErr}</span>}
</div>
</div>
)}
</Modal>
</section>
);
}
+39 -10
View File
@@ -669,10 +669,14 @@ export interface TariffCard {
priority: number;
category?: string;
window?: TariffWindow;
/** Flat price PER INCREMENT (an hourly flat rate) — not a whole-stay price. */
flatMinor?: number;
blocks?: TariffBlock[];
/** STEPPED ("up-to") table (defaultCard only); mutually exclusive with flat/blocks. */
steps?: TariffStep[];
/** WINDOW PACKAGE (windowed cards only): ONE total per contiguous window occurrence
* ("any presence in the window = this price"). Mirrors @parking/shared. */
packageMinor?: number;
dailyCapMinor?: number | null;
}
/** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay up to and including
@@ -702,6 +706,8 @@ export function isTariffV2(t: TariffStructure): t is TariffStructureV2 {
export interface TariffVersion {
id: string;
tariffId: string;
/** Optional human label, stamped at publish (e.g. carried from a lab draft). */
name?: string | null;
effectiveFrom: string;
currency: string;
structure: TariffStructure;
@@ -723,6 +729,7 @@ export function publishTariffVersion(body: {
currency: string;
structure: TariffStructure;
effectiveFrom?: string;
name?: string;
}): Promise<TariffVersion> {
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
}
@@ -761,18 +768,40 @@ export function simulateTariff(body: SimulateBody): Promise<SimulateResult> {
return apiFetch("/api/tariff/simulate", { method: "POST", body: JSON.stringify(body) });
}
export interface SimSessionLoad {
identity: string;
enteredAt: string;
exitedAt: string | null;
payments: SimPayment[];
category: string | null;
tariffVersionId: string | null;
// --- Tariff Lab drafts ------------------------------------------------------
// Mutable experimental rate cards — the lab composes + simulates these, and
// publishing one goes through the normal immutable-version path above.
export interface TariffDraft {
id: string;
name: string;
currency: string;
structure: TariffStructure;
createdBy: string | null;
createdAt: string;
updatedAt: string;
}
/** Prefill the lab from a real ledger session. */
export function loadSimSession(identity: string): Promise<SimSessionLoad> {
return apiFetch(`/api/tariff/simulate/session/${encodeURIComponent(identity)}`);
export function fetchTariffDrafts(): Promise<{ drafts: TariffDraft[] }> {
return apiFetch("/api/tariff/drafts");
}
export interface TariffDraftBody {
name: string;
currency: string;
structure: TariffStructure;
}
export function createTariffDraft(body: TariffDraftBody): Promise<TariffDraft> {
return apiFetch("/api/tariff/drafts", { method: "POST", body: JSON.stringify(body) });
}
export function updateTariffDraft(id: string, body: TariffDraftBody): Promise<TariffDraft> {
return apiFetch(`/api/tariff/drafts/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify(body) });
}
export function deleteTariffDraft(id: string): Promise<void> {
return apiFetch(`/api/tariff/drafts/${encodeURIComponent(id)}`, { method: "DELETE" });
}
// --- Subscriptions --------------------------------------------------------
+26 -16
View File
@@ -319,25 +319,30 @@ export const en: Catalog = {
bandDuration: "Band duration",
hoursUnit: "hours",
egHours: "e.g. 2",
pricePerIncrement: "Price / increment",
pricePerIncrement: "Price / increment (per hour)",
thereafter: "thereafter (open-ended)",
remove: "Remove",
addBlock: "+ Add block",
publishNewVersion: "Publish new version",
publishing: "Publishing…",
versionNamePh: "Version name (optional), e.g. Summer 2026",
publishedOk: "New tariff version published — it's now the active rate.",
defaultCard: "Base rate (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",
modeFlat: "Flat price / hour",
modeStepped: "By duration (up-to)",
modePackage: "Window package (one total)",
packageHint:
"ONE total for any presence inside this tier's window — leaving earlier costs the same. Touching the window on two different nights charges the package twice (once per night). Hours outside the window are priced by the base rate.",
packageTotal: "Package total",
steppedHint:
"Set the TOTAL price for a stay up to a given time (e.g. up to 3h = 500). The first row whose limit ≥ the duration wins (the limit is inclusive). The last row's total repeats as a per-day price for longer stays.",
stepUpTo: "Up to",
stepTotal: "Total price",
addStep: "+ Add row",
steppedTiersConflict:
"⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price'. Publishing is blocked until this is fixed.",
"⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price / hour'. Publishing is blocked until this is fixed.",
tiersAdvanced: "Advanced: time & seasonal tiers",
tiersHint: "Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.",
tierName: "Name",
@@ -513,21 +518,26 @@ export const en: Catalog = {
lab: {
title: "Tariff Lab",
intro:
"Test rates in time (day/night windows, daily caps, overstay) in seconds, with no waiting. Pricing uses the same logic as the booth; nothing is written to the ledger.",
loadTicket: "Load from a real ticket",
loadTicketPh: "Ticket number / identity",
load: "Load",
loaded: "Loaded session {{id}}",
tariffVersion: "Tariff version",
activeVersion: "Active version (current)",
"Compose experimental rate cards and price hypothetical stays against them — nothing goes live until you publish. Pricing uses the same logic as the booth; nothing is written to the ledger.",
drafts: "Lab tariffs",
newDraft: "New draft",
activeTariff: "Active tariff",
published: "Published versions",
noDrafts: "No lab tariffs yet — create a draft to experiment.",
edit: "Edit",
publish: "Publish",
delete: "Delete",
draftName: "Name",
draftNamePh: "e.g. Winter proposal",
saveDraft: "Save draft",
savingDraft: "Saving…",
newDraftTitle: "New lab tariff",
editDraftTitle: "Edit lab tariff",
confirmPublish: 'Publish "{{name}}" as the new live rate card? It takes effect immediately.',
confirmDelete: 'Delete lab tariff "{{name}}"?',
entered: "Entered",
asOf: "As of (now/exit)",
exit: "Exit",
now: "Now",
category: "Category",
categoryPh: "e.g. bus (blank = car)",
payment: "Payment",
paid: "paid",
graceMin: "grace (min)",
price: "Compute price",
pricing: "Pricing…",
outcome: "Outcome",
+26 -16
View File
@@ -322,25 +322,30 @@ export const sq = {
bandDuration: "Kohëzgjatja e brezit",
hoursUnit: "orë",
egHours: "p.sh. 2",
pricePerIncrement: "Çmimi / interval",
pricePerIncrement: "Çmimi / interval (orë)",
thereafter: "më pas (i hapur)",
remove: "Hiq",
addBlock: "+ Shto bllok",
publishNewVersion: "Publiko version të ri",
publishing: "Duke publikuar…",
versionNamePh: "Emri i versionit (opsional), p.sh. Vera 2026",
publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.",
defaultCard: "Tarifa bazë (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",
modeFlat: "Çmim fiks / orë",
modeStepped: "Sipas kohëzgjatjes (deri-në)",
modePackage: "Paketë dritareje (një total)",
packageHint:
"NJË çmim total për çdo prani brenda dritares së këtij niveli — largimi më herët kushton njësoj. Prekja e dritares në dy net të ndryshme e faturon paketën dy herë (një herë për natë). Orët jashtë dritares vlerësohen me tarifën bazë.",
packageTotal: "Çmimi i paketës",
steppedHint:
"Vendos çmimin TOTAL për një qëndrim deri në një kohë të caktuar (p.sh. deri 3 orë = 500). Fiton rreshti i parë me kufi ≥ kohëzgjatjes (kufiri përfshihet). Totali i rreshtit të fundit përsëritet si çmim ditor për qëndrime më të gjata.",
stepUpTo: "Deri në",
stepTotal: "Çmimi total",
addStep: "+ Shto rresht",
steppedTiersConflict:
"⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks'. Publikimi bllokohet derisa kjo të rregullohet.",
"⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks / orë'. Publikimi bllokohet derisa kjo të rregullohet.",
tiersAdvanced: "Të avancuara: nivele kohore & sezonale",
tiersHint: "Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.",
tierName: "Emri",
@@ -525,21 +530,26 @@ export const sq = {
lab: {
title: "Lab Tarife",
intro:
"Testo tarifat në kohë (dritare ditë/natë, kufi ditor, qëndrim tej afatit) në sekonda, pa pritur orë. Çmimi llogaritet me të njëjtën logjikë si kabina; nuk shkruhet asgjë në ledger.",
loadTicket: "Ngarko nga një biletë reale",
loadTicketPh: "Numri i biletës / identiteti",
load: "Ngarko",
loaded: "U ngarkua sesioni {{id}}",
tariffVersion: "Versioni i tarifës",
activeVersion: "Versioni aktiv (i tanishëm)",
"Kompozo tarifa eksperimentale dhe llogarit qëndrime hipotetike kundrejt tyre — asgjë nuk hyn në fuqi pa u publikuar. Çmimi llogaritet me të njëjtën logjikë si kabina; nuk shkruhet asgjë në ledger.",
drafts: "Tarifa laboratori",
newDraft: "Draft i ri",
activeTariff: "Tarifa aktive",
published: "Versione të publikuara",
noDrafts: "Ende pa tarifa laboratori — krijo një draft për të eksperimentuar.",
edit: "Ndrysho",
publish: "Publiko",
delete: "Fshi",
draftName: "Emri",
draftNamePh: "p.sh. Propozimi i dimrit",
saveDraft: "Ruaj draftin",
savingDraft: "Duke ruajtur…",
newDraftTitle: "Tarifë e re laboratori",
editDraftTitle: "Ndrysho tarifën e laboratorit",
confirmPublish: 'Të publikohet "{{name}}" si karta e re aktive e çmimeve? Hyn në fuqi menjëherë.',
confirmDelete: 'Të fshihet tarifa e laboratorit "{{name}}"?',
entered: "Hyrja",
asOf: "Deri më (tani/dalja)",
exit: "Dalja",
now: "Tani",
category: "Kategoria",
categoryPh: "p.sh. bus (bosh = makinë)",
payment: "Pagesa",
paid: "u pagua",
graceMin: "afati (min)",
price: "Llogarit çmimin",
pricing: "Duke llogaritur…",
outcome: "Rezultati",