This commit is contained in:
@@ -63,7 +63,10 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
|
|||||||
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
|
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
|
||||||
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
|
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
|
||||||
const shifts = shift.listShifts({ operator, from, to });
|
const shifts = shift.listShifts({ operator, from, to });
|
||||||
return { shifts, scope: canSeeAll ? "all" : "self" };
|
// Admins also get the distinct operator list (unfiltered) for the filter
|
||||||
|
// dropdown — operators don't see other names, so it's scope-gated.
|
||||||
|
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators() };
|
||||||
|
return { shifts, scope: "self" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
|
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
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 {
|
import {
|
||||||
computeFee,
|
computeFee,
|
||||||
isTariffV2,
|
isTariffV2,
|
||||||
@@ -25,10 +25,19 @@ interface PublishBody {
|
|||||||
structure: TariffStructure;
|
structure: TariffStructure;
|
||||||
/** When this version takes effect (ISO-8601). Defaults to now. */
|
/** When this version takes effect (ISO-8601). Defaults to now. */
|
||||||
effectiveFrom?: string;
|
effectiveFrom?: string;
|
||||||
|
/** Optional human label (e.g. carried from the lab draft being published). */
|
||||||
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SITE_TARIFF_NAME = "Site tariff";
|
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.
|
/** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write.
|
||||||
* Provide a structure source (one of): `tariffVersionId`, inline `structure`, or
|
* Provide a structure source (one of): `tariffVersionId`, inline `structure`, or
|
||||||
* neither (uses the active version). */
|
* neither (uses the active version). */
|
||||||
@@ -80,19 +89,14 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
"/api/tariff/versions",
|
"/api/tariff/versions",
|
||||||
{ preHandler: writeGuard },
|
{ preHandler: writeGuard },
|
||||||
async (req, reply) => {
|
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) {
|
if (!currency || typeof currency !== "string" || currency.length < 3) {
|
||||||
return reply.code(400).send({ error: "currency (ISO 4217) required" });
|
return reply.code(400).send({ error: "currency (ISO 4217) required" });
|
||||||
}
|
}
|
||||||
// For a windowed (V2) structure, stamp the wall-clock timezone from SITE config
|
// 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
|
// (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.
|
// validation that requires tz passes. A V1 (bare) structure is left untouched.
|
||||||
let toStore: TariffStructure = structure;
|
const toStore = stampSiteTz(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 problems = validateTariffStructure(toStore);
|
const problems = validateTariffStructure(toStore);
|
||||||
if (problems.length) {
|
if (problems.length) {
|
||||||
@@ -128,6 +132,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
const row = {
|
const row = {
|
||||||
id,
|
id,
|
||||||
tariffId,
|
tariffId,
|
||||||
|
name: typeof name === "string" && name.trim() ? name.trim() : null,
|
||||||
effectiveFrom: effective,
|
effectiveFrom: effective,
|
||||||
currency,
|
currency,
|
||||||
structure: toStore as unknown as Record<string, unknown>,
|
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). */
|
/** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */
|
||||||
function tariffVersionIdFor(whenIso: string): string | null {
|
function tariffVersionIdFor(whenIso: string): string | null {
|
||||||
const tariffId = ensureSiteTariff();
|
const tariffId = ensureSiteTariff();
|
||||||
|
|||||||
@@ -234,4 +234,11 @@ describe("close signs a Z-report; listShifts reads it back", () => {
|
|||||||
await shift.open("bob"); await shift.close("bob");
|
await shift.open("bob"); await shift.close("bob");
|
||||||
expect(shift.listShifts({ operator: "alice" }).map((s) => s.operator)).toEqual(["alice"]);
|
expect(shift.listShifts({ operator: "alice" }).map((s) => s.operator)).toEqual(["alice"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("listOperators: distinct + sorted, includes the OPEN shift's operator", async () => {
|
||||||
|
await shift.open("bob"); await shift.close("bob");
|
||||||
|
await shift.open("bob"); await shift.close("bob"); // twice — must stay distinct
|
||||||
|
await shift.open("alice"); // open, no z-report yet
|
||||||
|
expect(shift.listOperators()).toEqual(["alice", "bob"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -177,6 +177,28 @@ export class ShiftService {
|
|||||||
* The open shift (no z_report yet) is intentionally excluded — it's not a
|
* The open shift (no z_report yet) is intentionally excluded — it's not a
|
||||||
* completed accountability period. Use `currentOpenShift()` for the live one.
|
* completed accountability period. Use `currentOpenShift()` for the live one.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Every operator that HAS a shift (closed z_reports + the open one, if any),
|
||||||
|
* distinct + sorted — feeds the admin filter dropdown so it can only ever ask
|
||||||
|
* for an operator that exists (the filter is an exact username match).
|
||||||
|
*/
|
||||||
|
listOperators(): string[] {
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.type, "shift_z_report"))
|
||||||
|
.all();
|
||||||
|
const names = new Set<string>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity;
|
||||||
|
if (op) names.add(op);
|
||||||
|
}
|
||||||
|
const open = this.currentOpenShift();
|
||||||
|
const openOp = open ? (((open.payload ?? {}) as { operator?: string }).operator ?? open.identity) : null;
|
||||||
|
if (openOp) names.add(openOp);
|
||||||
|
return [...names].sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
|
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
|
||||||
const rows = this.#db
|
const rows = this.#db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" href="data:," />
|
<link rel="icon" href="data:," />
|
||||||
|
<!-- Self-hosted primary face (offline appliance — no webfont CDN). Preload the
|
||||||
|
two weights on every screen so first paint doesn't flash the fallback. -->
|
||||||
|
<link rel="preload" href="/fonts/chakra-petch/chakra-petch-latin-400.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
|
<link rel="preload" href="/fonts/chakra-petch/chakra-petch-latin-600.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
<title>Parking System</title>
|
<title>Parking System</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
closeShift,
|
closeShift,
|
||||||
fetchEvents,
|
fetchEvents,
|
||||||
@@ -105,9 +105,17 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
|
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const q = useQuery({ queryKey: ["shifts", applied], queryFn: () => fetchShifts(applied) });
|
// keepPreviousData: every filter change makes a NEW query key; without it the
|
||||||
|
// data (and with it `scope`) goes undefined for the fetch round-trip, which
|
||||||
|
// unmounted the admin filter controls mid-interaction and blanked the list.
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ["shifts", applied],
|
||||||
|
queryFn: () => fetchShifts(applied),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
const isAdmin = q.data?.scope === "all";
|
const isAdmin = q.data?.scope === "all";
|
||||||
const closed = q.data?.shifts ?? [];
|
const closed = q.data?.shifts ?? [];
|
||||||
|
const operators = q.data?.operators ?? [];
|
||||||
|
|
||||||
// The current/open shift sits at the TOP of the list (when present + visible to me).
|
// The current/open shift sits at the TOP of the list (when present + visible to me).
|
||||||
const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed;
|
const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed;
|
||||||
@@ -169,7 +177,14 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<span className="label">{t("shifts.operator")}</span>
|
<span className="label">{t("shifts.operator")}</span>
|
||||||
<input className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)} placeholder={t("shifts.allOperators")} />
|
{/* A select over operators that HAVE shifts — the server filter is an
|
||||||
|
exact username match, so free text could only miss. */}
|
||||||
|
<select className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)}>
|
||||||
|
<option value="">{t("shifts.allOperators")}</option>
|
||||||
|
{operators.map((op) => (
|
||||||
|
<option key={op} value={op}>{op}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type SubscriptionPlan,
|
type SubscriptionPlan,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { currencyOptions } from "./lib/currencies.js";
|
||||||
|
|
||||||
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
|
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
|
||||||
// operator sells from (so the operator never types a price). Editing a plan PUBLISHES A
|
// operator sells from (so the operator never types a price). Editing a plan PUBLISHES A
|
||||||
@@ -348,7 +349,11 @@ export function SubscriptionPlansManager() {
|
|||||||
<label className="label">{t("plans.pricePer")}</label>
|
<label className="label">{t("plans.pricePer")}</label>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
|
<input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
|
||||||
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })} />
|
<select className="input w-auto" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })}>
|
||||||
|
{currencyOptions(form.currency).map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<span className="text-[0.75rem] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
|
<span className="text-[0.75rem] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+24
-554
@@ -1,267 +1,23 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { ApiError, fetchTariff, publishTariffVersion, type TariffState } from "./api.js";
|
||||||
ApiError,
|
import { TariffEditorForm, emptyForm, formFromActive, toStructure, type FormState } from "./TariffEditorForm.js";
|
||||||
fetchTariff,
|
|
||||||
isTariffV2,
|
|
||||||
publishTariffVersion,
|
|
||||||
type TariffBlock,
|
|
||||||
type TariffCard,
|
|
||||||
type TariffStep,
|
|
||||||
type TariffStructure,
|
|
||||||
type TariffState,
|
|
||||||
} from "./api.js";
|
|
||||||
|
|
||||||
// Tariff composer — the admin builds + edits the rate card at runtime. Publishing
|
// 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
|
// 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
|
// sessions reprice correctly. The form machinery is shared with the Tariff Lab's
|
||||||
// usability and converted to integer minor units on submit. See wiki/concepts/tariff.md.
|
// draft modal — see TariffEditorForm.tsx. To experiment without publishing, use the
|
||||||
|
// lab (a draft only becomes real through this same publish path). See
|
||||||
// Editable form mirror of TariffStructure, but money in major-unit strings.
|
// wiki/concepts/tariff.md.
|
||||||
// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the
|
|
||||||
// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes.
|
|
||||||
// The LAST block is always open-ended ("thereafter"): its hours field is unused
|
|
||||||
// and it has no bound. On submit, per-block hours accumulate into the engine's
|
|
||||||
// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null.
|
|
||||||
interface BlockForm {
|
|
||||||
hours: string; // duration of THIS band, in hours (ignored for the last block)
|
|
||||||
price: string; // major units, e.g. "2.00"
|
|
||||||
}
|
|
||||||
// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the
|
|
||||||
// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md.
|
|
||||||
interface StepForm {
|
|
||||||
hours: string; // inclusive upper bound of this tier, in hours (e.g. "3")
|
|
||||||
total: string; // TOTAL major units for a stay within this tier (e.g. "5.00")
|
|
||||||
}
|
|
||||||
// A pricing body the form edits: a flat rate, a marginal block ladder, or a stepped
|
|
||||||
// (up-to) total-by-duration table.
|
|
||||||
interface PricingForm {
|
|
||||||
mode: "ladder" | "flat" | "stepped";
|
|
||||||
flat: string; // major units (used when mode==="flat")
|
|
||||||
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
|
|
||||||
steps: StepForm[]; // up-to tiers (used when mode==="stepped")
|
|
||||||
dailyCap: string; // "" = no cap (ladder only)
|
|
||||||
}
|
|
||||||
// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
|
|
||||||
interface TierForm {
|
|
||||||
name: string;
|
|
||||||
priority: string;
|
|
||||||
category: string; // "" = applies to all categories
|
|
||||||
dow: number[]; // selected days 0..6; empty = every day
|
|
||||||
fromHour: string; // "" = all day
|
|
||||||
toHour: string;
|
|
||||||
dateFrom: string; // "" = unbounded
|
|
||||||
dateTo: string;
|
|
||||||
pricing: PricingForm;
|
|
||||||
}
|
|
||||||
interface FormState {
|
|
||||||
currency: string;
|
|
||||||
gracePeriodEntryMin: string;
|
|
||||||
incrementMin: string;
|
|
||||||
lostTicket: string;
|
|
||||||
gracePeriodExitMin: string;
|
|
||||||
// The default (always-active) card — its own flat/ladder body + daily cap.
|
|
||||||
base: PricingForm;
|
|
||||||
// Optional time/category tiers. Empty ⇒ a bare V1 structure is published.
|
|
||||||
tiers: TierForm[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
|
||||||
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
|
||||||
|
|
||||||
function emptySteps(): StepForm[] {
|
|
||||||
return [
|
|
||||||
{ hours: "1", total: "2.00" },
|
|
||||||
{ hours: "3", total: "5.00" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
function emptyLadder(): PricingForm {
|
|
||||||
return {
|
|
||||||
mode: "ladder",
|
|
||||||
flat: "0.00",
|
|
||||||
dailyCap: "",
|
|
||||||
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
|
|
||||||
steps: emptySteps(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function emptyTier(): TierForm {
|
|
||||||
return {
|
|
||||||
name: "",
|
|
||||||
priority: "10",
|
|
||||||
category: "",
|
|
||||||
dow: [],
|
|
||||||
fromHour: "",
|
|
||||||
toHour: "",
|
|
||||||
dateFrom: "",
|
|
||||||
dateTo: "",
|
|
||||||
pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyForm(): FormState {
|
|
||||||
return {
|
|
||||||
currency: "EUR",
|
|
||||||
gracePeriodEntryMin: "15",
|
|
||||||
incrementMin: "60",
|
|
||||||
lostTicket: "20.00",
|
|
||||||
gracePeriodExitMin: "15",
|
|
||||||
base: emptyLadder(),
|
|
||||||
tiers: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band
|
|
||||||
// hours the form edits. Open-ended last band has no hours. Legacy bounded tails still
|
|
||||||
// load (shown as their own band).
|
|
||||||
function blocksToForm(blocks: TariffBlock[]): BlockForm[] {
|
|
||||||
let prev = 0;
|
|
||||||
return blocks.map((b) => {
|
|
||||||
if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) };
|
|
||||||
const hours = (b.uptoMin - prev) / 60;
|
|
||||||
prev = b.uptoMin;
|
|
||||||
return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits.
|
|
||||||
function stepsToForm(steps: TariffStep[]): StepForm[] {
|
|
||||||
return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, or stepped).
|
|
||||||
function pricingFromCard(c: {
|
|
||||||
flatMinor?: number;
|
|
||||||
blocks?: TariffBlock[];
|
|
||||||
steps?: TariffStep[];
|
|
||||||
dailyCapMinor?: number | null;
|
|
||||||
}): PricingForm {
|
|
||||||
if (c.steps != null && c.steps.length > 0) {
|
|
||||||
return { mode: "stepped", flat: "0.00", dailyCap: "", blocks: emptyLadder().blocks, steps: stepsToForm(c.steps) };
|
|
||||||
}
|
|
||||||
if (c.flatMinor != null) {
|
|
||||||
return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks, steps: emptySteps() };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
mode: "ladder",
|
|
||||||
flat: "0.00",
|
|
||||||
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
|
||||||
blocks: blocksToForm(c.blocks ?? []),
|
|
||||||
steps: emptySteps(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function tierFromCard(c: TariffCard): TierForm {
|
|
||||||
const w = c.window ?? {};
|
|
||||||
return {
|
|
||||||
name: c.name,
|
|
||||||
priority: String(c.priority),
|
|
||||||
category: c.category ?? "",
|
|
||||||
dow: w.dow ? [...w.dow] : [],
|
|
||||||
fromHour: w.fromHour ?? "",
|
|
||||||
toHour: w.toHour ?? "",
|
|
||||||
dateFrom: w.dateFrom ?? "",
|
|
||||||
dateTo: w.dateTo ?? "",
|
|
||||||
pricing: pricingFromCard(c),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formFromActive(s: TariffState): FormState {
|
|
||||||
const v = s.active;
|
|
||||||
if (!v) return emptyForm();
|
|
||||||
const st = v.structure;
|
|
||||||
const common = {
|
|
||||||
currency: v.currency,
|
|
||||||
gracePeriodEntryMin: String(st.gracePeriodEntryMin),
|
|
||||||
incrementMin: String(st.incrementMin),
|
|
||||||
lostTicket: toMajor(st.lostTicketMinor),
|
|
||||||
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
|
||||||
};
|
|
||||||
if (isTariffV2(st)) {
|
|
||||||
return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) };
|
|
||||||
}
|
|
||||||
// V1: the bare ladder becomes the default card body; no tiers.
|
|
||||||
return { ...common, base: pricingFromCard(st), tiers: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a tariff card's pricing body (flat XOR ladder XOR stepped) from a PricingForm.
|
|
||||||
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "steps" | "dailyCapMinor"> {
|
|
||||||
if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
|
|
||||||
if (p.mode === "stepped") {
|
|
||||||
// Each row's `hours` IS the inclusive threshold (the matrix "up to N hours").
|
|
||||||
const steps: TariffStep[] = p.steps.map((s) => ({
|
|
||||||
uptoMin: Math.round(Number(s.hours || "0") * 60),
|
|
||||||
totalMinor: toMinor(s.total),
|
|
||||||
}));
|
|
||||||
return { steps };
|
|
||||||
}
|
|
||||||
// Accumulate each band's hours into cumulative uptoMin (min); last band open-ended.
|
|
||||||
const last = p.blocks.length - 1;
|
|
||||||
let cum = 0;
|
|
||||||
const blocks: TariffBlock[] = p.blocks.map((b, i) => {
|
|
||||||
if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
|
|
||||||
cum += Math.round(Number(b.hours || "0") * 60);
|
|
||||||
return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) };
|
|
||||||
});
|
|
||||||
return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function tierToCard(tr: TierForm): TariffCard {
|
|
||||||
const window: TariffCard["window"] = {};
|
|
||||||
if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b);
|
|
||||||
if (tr.fromHour && tr.toHour) {
|
|
||||||
window.fromHour = tr.fromHour;
|
|
||||||
window.toHour = tr.toHour;
|
|
||||||
}
|
|
||||||
if (tr.dateFrom) window.dateFrom = tr.dateFrom;
|
|
||||||
if (tr.dateTo) window.dateTo = tr.dateTo;
|
|
||||||
const card: TariffCard = {
|
|
||||||
name: tr.name.trim() || "tier",
|
|
||||||
priority: Math.round(Number(tr.priority || "0")),
|
|
||||||
...pricingToCardBody(tr.pricing),
|
|
||||||
};
|
|
||||||
if (tr.category.trim()) card.category = tr.category.trim();
|
|
||||||
if (Object.keys(window).length > 0) card.window = window;
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toStructure(f: FormState): TariffStructure {
|
|
||||||
const common = {
|
|
||||||
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
|
|
||||||
incrementMin: Math.round(Number(f.incrementMin)),
|
|
||||||
lostTicketMinor: toMinor(f.lostTicket),
|
|
||||||
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
|
|
||||||
overstay: "reprice" as const,
|
|
||||||
};
|
|
||||||
const baseBody = pricingToCardBody(f.base);
|
|
||||||
|
|
||||||
// NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants
|
|
||||||
// tiers gets exactly today's shape; the server leaves it untouched).
|
|
||||||
if (f.tiers.length === 0) {
|
|
||||||
if (f.base.mode === "stepped") {
|
|
||||||
// A stepped V1: the up-to table replaces the ladder (blocks empty, no cap).
|
|
||||||
return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null };
|
|
||||||
}
|
|
||||||
if (f.base.mode === "flat") {
|
|
||||||
// A flat V1: a single open-ended block at the flat rate (V1 has no flat field).
|
|
||||||
return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null };
|
|
||||||
}
|
|
||||||
return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here).
|
|
||||||
return {
|
|
||||||
...common,
|
|
||||||
version: 2,
|
|
||||||
tz: "",
|
|
||||||
defaultCard: { name: "default", priority: 0, ...baseBody },
|
|
||||||
windowedCards: f.tiers.map(tierToCard),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TariffComposer() {
|
export function TariffComposer() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<TariffState | null>(null);
|
const [state, setState] = useState<TariffState | null>(null);
|
||||||
const [form, setForm] = useState<FormState>(emptyForm);
|
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 [saving, setSaving] = useState(false);
|
||||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
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 }));
|
.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() {
|
async function publish() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
try {
|
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();
|
const fresh = await fetchTariff();
|
||||||
setState(fresh);
|
setState(fresh);
|
||||||
|
setVersionName("");
|
||||||
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const text =
|
const text =
|
||||||
@@ -359,6 +63,7 @@ export function TariffComposer() {
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="mb-4 text-[0.75rem] text-term-muted">
|
<p className="mb-4 text-[0.75rem] text-term-muted">
|
||||||
|
{state.active.name ? `${state.active.name} — ` : ""}
|
||||||
{t("tariff.activeSince", {
|
{t("tariff.activeSince", {
|
||||||
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
||||||
count: state.versions.length,
|
count: state.versions.length,
|
||||||
@@ -366,114 +71,15 @@ export function TariffComposer() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
<TariffEditorForm form={form} onChange={setForm} />
|
||||||
<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
|
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||||
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
|
<input
|
||||||
className="input w-40"
|
className="input w-64"
|
||||||
value={tr.name}
|
value={versionName}
|
||||||
onChange={(e) => setTier(i, { name: e.target.value })}
|
onChange={(e) => setVersionName(e.target.value)}
|
||||||
placeholder={t("tariff.tierName")}
|
placeholder={t("tariff.versionNamePh")}
|
||||||
/>
|
/>
|
||||||
<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}>
|
<button type="button" className="btn btn-primary btn-lg" onClick={publish} disabled={saving}>
|
||||||
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
||||||
</button>
|
</button>
|
||||||
@@ -484,139 +90,3 @@ export function TariffComposer() {
|
|||||||
</section>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,609 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { currencyOptions } from "./lib/currencies.js";
|
||||||
|
import {
|
||||||
|
isTariffV2,
|
||||||
|
type TariffBlock,
|
||||||
|
type TariffCard,
|
||||||
|
type TariffStep,
|
||||||
|
type TariffStructure,
|
||||||
|
type TariffState,
|
||||||
|
} from "./api.js";
|
||||||
|
|
||||||
|
// The tariff EDITOR FORM — the rate-card composer's form machinery (state shape,
|
||||||
|
// structure↔form converters, and the editing UI), extracted so two hosts can share
|
||||||
|
// it: the /setup/tariff page (edits + publishes the live card) and the Tariff Lab's
|
||||||
|
// draft modal (edits an experimental card). The host owns the FormState and the
|
||||||
|
// submit action; this module owns everything between. Amounts are entered in major
|
||||||
|
// units (e.g. euros) and converted to integer minor units on submit.
|
||||||
|
// See wiki/concepts/tariff.md.
|
||||||
|
|
||||||
|
// Editable form mirror of TariffStructure, but money in major-unit strings.
|
||||||
|
// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the
|
||||||
|
// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes.
|
||||||
|
// The LAST block is always open-ended ("thereafter"): its hours field is unused
|
||||||
|
// and it has no bound. On submit, per-block hours accumulate into the engine's
|
||||||
|
// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null.
|
||||||
|
export interface BlockForm {
|
||||||
|
hours: string; // duration of THIS band, in hours (ignored for the last block)
|
||||||
|
price: string; // major units, e.g. "2.00"
|
||||||
|
}
|
||||||
|
// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the
|
||||||
|
// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md.
|
||||||
|
export interface StepForm {
|
||||||
|
hours: string; // inclusive upper bound of this tier, in hours (e.g. "3")
|
||||||
|
total: string; // TOTAL major units for a stay within this tier (e.g. "5.00")
|
||||||
|
}
|
||||||
|
// A pricing body the form edits: a per-increment flat rate, a marginal block ladder,
|
||||||
|
// a stepped (up-to) total-by-duration table, or a whole-window package (tiers only).
|
||||||
|
export interface PricingForm {
|
||||||
|
mode: "ladder" | "flat" | "stepped" | "package";
|
||||||
|
flat: string; // major units PER INCREMENT (used when mode==="flat")
|
||||||
|
packageTotal: string; // major units for the WHOLE window occurrence (mode==="package")
|
||||||
|
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
|
||||||
|
steps: StepForm[]; // up-to tiers (used when mode==="stepped")
|
||||||
|
dailyCap: string; // "" = no cap (ladder only)
|
||||||
|
}
|
||||||
|
// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
|
||||||
|
export interface TierForm {
|
||||||
|
name: string;
|
||||||
|
priority: string;
|
||||||
|
category: string; // "" = applies to all categories
|
||||||
|
dow: number[]; // selected days 0..6; empty = every day
|
||||||
|
fromHour: string; // "" = all day
|
||||||
|
toHour: string;
|
||||||
|
dateFrom: string; // "" = unbounded
|
||||||
|
dateTo: string;
|
||||||
|
pricing: PricingForm;
|
||||||
|
}
|
||||||
|
export interface FormState {
|
||||||
|
currency: string;
|
||||||
|
gracePeriodEntryMin: string;
|
||||||
|
incrementMin: string;
|
||||||
|
lostTicket: string;
|
||||||
|
gracePeriodExitMin: string;
|
||||||
|
// The default (always-active) card — its own flat/ladder body + daily cap.
|
||||||
|
base: PricingForm;
|
||||||
|
// Optional time/category tiers. Empty ⇒ a bare V1 structure is published.
|
||||||
|
tiers: TierForm[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
||||||
|
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
||||||
|
|
||||||
|
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: "ALL",
|
||||||
|
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>
|
||||||
|
<select className="input w-24" value={form.currency} onChange={(e) => set("currency", e.target.value)}>
|
||||||
|
{currencyOptions(form.currency).map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label className="label">{t("tariff.freeEntryGrace")}</label>
|
||||||
|
<input className="input w-32" value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
||||||
|
<label className="label">{t("tariff.billingIncrement")}</label>
|
||||||
|
<input className="input w-32" value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
||||||
|
<label className="label">{t("tariff.lostTicketFee")}</label>
|
||||||
|
<input className="input w-32" value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
||||||
|
<label className="label">{t("tariff.exitGrace")}</label>
|
||||||
|
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+267
-103
@@ -1,21 +1,30 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
|
ApiError,
|
||||||
|
createTariffDraft,
|
||||||
|
deleteTariffDraft,
|
||||||
fetchTariff,
|
fetchTariff,
|
||||||
loadSimSession,
|
fetchTariffDrafts,
|
||||||
|
publishTariffVersion,
|
||||||
simulateTariff,
|
simulateTariff,
|
||||||
|
updateTariffDraft,
|
||||||
type SimulateResult,
|
type SimulateResult,
|
||||||
type SimPayment,
|
type TariffDraft,
|
||||||
type TariffState,
|
type TariffState,
|
||||||
} from "./api.js";
|
} 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";
|
import { formatMoney, formatDuration } from "./lib/format.js";
|
||||||
|
|
||||||
// The TARIFF LAB — a pure session-pricing simulator. Test rates "in time" (overnight
|
// The TARIFF LAB — a sandbox for composing + pricing EXPERIMENTAL rate cards. Drafts
|
||||||
// windows, daily caps, overstay) in seconds instead of waiting hours, against ANY
|
// live in their own mutable table (tariff_drafts), so experimenting never churns the
|
||||||
// published tariff version, with no real ledger writes. Build a hypothetical session
|
// immutable published versions or risks a half-baked card going live: the admin
|
||||||
// (entry, optional payment, "now") OR load a real ticket and re-evaluate it at any
|
// composes a draft in the modal (the same form the composer page uses), simulates
|
||||||
// instant. Prices via the SAME `priceSession` the booth uses (server), so the lab and
|
// hypothetical stays against it (entry + exit, nothing else), and only when satisfied
|
||||||
// the live booth can never diverge. See wiki/concepts/tariff.md, booth-exit-flow.md.
|
// 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. */
|
/** <input type="datetime-local"> wants "YYYY-MM-DDTHH:mm" in LOCAL time. */
|
||||||
function toLocalInput(iso: string): string {
|
function toLocalInput(iso: string): string {
|
||||||
@@ -33,50 +42,76 @@ function nowLocal(): string {
|
|||||||
return toLocalInput(new Date().toISOString());
|
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() {
|
export function TariffLab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<TariffState | null>(null);
|
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 [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 [entered, setEntered] = useState<string>(() => {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setHours(d.getHours() - 3); // default: a 3h-ago entry
|
d.setHours(d.getHours() - 3); // default: a 3h-ago entry
|
||||||
return toLocalInput(d.toISOString());
|
return toLocalInput(d.toISOString());
|
||||||
});
|
});
|
||||||
const [asOf, setAsOf] = useState<string>(nowLocal);
|
const [exit, setExit] = 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 [result, setResult] = useState<SimulateResult | null>(null);
|
const [result, setResult] = useState<SimulateResult | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchTariff()
|
refresh().catch((e) => setErr((e as Error).message));
|
||||||
.then(setState)
|
|
||||||
.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() {
|
async function run() {
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const payments: SimPayment[] = paid
|
|
||||||
? [{ paidAt: fromLocalInput(paidAt), graceExitMin: graceMin.trim() === "" ? null : Number(graceMin) }]
|
|
||||||
: [];
|
|
||||||
const r = await simulateTariff({
|
const r = await simulateTariff({
|
||||||
enteredAt: fromLocalInput(entered),
|
enteredAt: fromLocalInput(entered),
|
||||||
asOf: fromLocalInput(asOf),
|
asOf: fromLocalInput(exit),
|
||||||
payments,
|
// A draft carries its own structure+currency; a historical version is
|
||||||
category: category.trim() || undefined,
|
// referenced by id; otherwise the ACTIVE version.
|
||||||
tariffVersionId: versionId || undefined,
|
...(selectedDraft
|
||||||
|
? { structure: selectedDraft.structure, currency: selectedDraft.currency }
|
||||||
|
: selectedVersion
|
||||||
|
? { tariffVersionId: selectedVersion.id }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
setResult(r);
|
setResult(r);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -87,103 +122,116 @@ export function TariffLab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTicket() {
|
// --- draft actions ---
|
||||||
setLoadMsg(null);
|
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);
|
setErr(null);
|
||||||
try {
|
try {
|
||||||
const s = await loadSimSession(ticket.trim());
|
await deleteTariffDraft(d.id);
|
||||||
setEntered(toLocalInput(s.enteredAt));
|
await refresh();
|
||||||
setAsOf(s.exitedAt ? toLocalInput(s.exitedAt) : nowLocal());
|
select({ kind: "active" });
|
||||||
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 }));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr((e as Error).message);
|
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 (
|
return (
|
||||||
<section className="px-4 py-6">
|
<section className="px-4 py-6">
|
||||||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("lab.title")}</h2>
|
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("lab.title")}</h2>
|
||||||
<p className="hint mb-4">{t("lab.intro")}</p>
|
<p className="hint mb-4">{t("lab.intro")}</p>
|
||||||
|
|
||||||
{/* Load a real ticket */}
|
<div className="flex flex-col gap-4 lg:flex-row">
|
||||||
<div className="card card-body mb-4 flex flex-wrap items-end gap-2">
|
{/* Main: the hypothetical stay + result, priced against the selection. */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="min-w-0 flex-1">
|
||||||
<label className="label">{t("lab.loadTicket")}</label>
|
{/* What we're pricing against + draft actions. */}
|
||||||
<input
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
className="input w-56"
|
<span className="rounded bg-term-panel-2 px-2 py-1 text-[0.75rem] text-term-cyan">
|
||||||
value={ticket}
|
{selectedDraft
|
||||||
onChange={(e) => setTicket(e.target.value)}
|
? selectedDraft.name
|
||||||
placeholder={t("lab.loadTicketPh")}
|
: selectedVersion
|
||||||
/>
|
? selectedVersion.name ?? new Date(selectedVersion.effectiveFrom).toLocaleString()
|
||||||
</div>
|
: t("lab.activeTariff")}
|
||||||
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}>
|
</span>
|
||||||
{t("lab.load")}
|
{selectedDraft && (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => editDraft(selectedDraft)}>
|
||||||
|
{t("lab.edit")}
|
||||||
</button>
|
</button>
|
||||||
{loadMsg && <span className="text-[0.75rem] text-term-green">{loadMsg}</span>}
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Hypothetical session inputs */}
|
|
||||||
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
<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>
|
|
||||||
|
|
||||||
<label className="label">{t("lab.entered")}</label>
|
<label className="label">{t("lab.entered")}</label>
|
||||||
<input type="datetime-local" className="input w-64" value={entered} onChange={(e) => setEntered(e.target.value)} />
|
<input type="datetime-local" className="input w-64" value={entered} onChange={(e) => setEntered(e.target.value)} />
|
||||||
|
|
||||||
<label className="label">{t("lab.asOf")}</label>
|
<label className="label">{t("lab.exit")}</label>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<input type="datetime-local" className="input w-64" value={asOf} onChange={(e) => setAsOf(e.target.value)} />
|
<input type="datetime-local" className="input w-64" value={exit} onChange={(e) => setExit(e.target.value)} />
|
||||||
<button type="button" className="btn btn-sm" onClick={() => setAsOf(nowLocal())}>
|
<button type="button" className="btn btn-sm" onClick={() => setExit(nowLocal())}>
|
||||||
{t("lab.now")}
|
{t("lab.now")}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<label className="label">{t("lab.category")}</label>
|
|
||||||
<input
|
|
||||||
className="input w-40"
|
|
||||||
value={category}
|
|
||||||
onChange={(e) => setCategory(e.target.value)}
|
|
||||||
placeholder={t("lab.categoryPh")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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)} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center gap-3">
|
<div className="mt-4 flex items-center gap-3">
|
||||||
@@ -203,7 +251,7 @@ export function TariffLab() {
|
|||||||
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
|
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
|
||||||
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
|
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
|
||||||
<dd className="text-term-text">
|
<dd className="text-term-text">
|
||||||
{formatDuration(result.pricing.periodStart, fromLocalInput(asOf))}
|
{formatDuration(result.pricing.periodStart, fromLocalInput(exit))}
|
||||||
{result.pricing.overstay && (
|
{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">
|
<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")}
|
{t("lab.overstay")}
|
||||||
@@ -243,6 +291,122 @@ export function TariffLab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-10
@@ -669,10 +669,14 @@ export interface TariffCard {
|
|||||||
priority: number;
|
priority: number;
|
||||||
category?: string;
|
category?: string;
|
||||||
window?: TariffWindow;
|
window?: TariffWindow;
|
||||||
|
/** Flat price PER INCREMENT (an hourly flat rate) — not a whole-stay price. */
|
||||||
flatMinor?: number;
|
flatMinor?: number;
|
||||||
blocks?: TariffBlock[];
|
blocks?: TariffBlock[];
|
||||||
/** STEPPED ("up-to") table (defaultCard only); mutually exclusive with flat/blocks. */
|
/** STEPPED ("up-to") table (defaultCard only); mutually exclusive with flat/blocks. */
|
||||||
steps?: TariffStep[];
|
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;
|
dailyCapMinor?: number | null;
|
||||||
}
|
}
|
||||||
/** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay up to and including
|
/** 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 {
|
export interface TariffVersion {
|
||||||
id: string;
|
id: string;
|
||||||
tariffId: string;
|
tariffId: string;
|
||||||
|
/** Optional human label, stamped at publish (e.g. carried from a lab draft). */
|
||||||
|
name?: string | null;
|
||||||
effectiveFrom: string;
|
effectiveFrom: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
structure: TariffStructure;
|
structure: TariffStructure;
|
||||||
@@ -723,6 +729,7 @@ export function publishTariffVersion(body: {
|
|||||||
currency: string;
|
currency: string;
|
||||||
structure: TariffStructure;
|
structure: TariffStructure;
|
||||||
effectiveFrom?: string;
|
effectiveFrom?: string;
|
||||||
|
name?: string;
|
||||||
}): Promise<TariffVersion> {
|
}): Promise<TariffVersion> {
|
||||||
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
|
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) });
|
return apiFetch("/api/tariff/simulate", { method: "POST", body: JSON.stringify(body) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SimSessionLoad {
|
// --- Tariff Lab drafts ------------------------------------------------------
|
||||||
identity: string;
|
// Mutable experimental rate cards — the lab composes + simulates these, and
|
||||||
enteredAt: string;
|
// publishing one goes through the normal immutable-version path above.
|
||||||
exitedAt: string | null;
|
|
||||||
payments: SimPayment[];
|
export interface TariffDraft {
|
||||||
category: string | null;
|
id: string;
|
||||||
tariffVersionId: string | null;
|
name: string;
|
||||||
|
currency: string;
|
||||||
|
structure: TariffStructure;
|
||||||
|
createdBy: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prefill the lab from a real ledger session. */
|
export function fetchTariffDrafts(): Promise<{ drafts: TariffDraft[] }> {
|
||||||
export function loadSimSession(identity: string): Promise<SimSessionLoad> {
|
return apiFetch("/api/tariff/drafts");
|
||||||
return apiFetch(`/api/tariff/simulate/session/${encodeURIComponent(identity)}`);
|
}
|
||||||
|
|
||||||
|
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 --------------------------------------------------------
|
// --- Subscriptions --------------------------------------------------------
|
||||||
@@ -1122,6 +1151,8 @@ export interface ShiftSummary extends ShiftSourceSplit {
|
|||||||
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
|
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
|
||||||
shifts: ShiftSummary[];
|
shifts: ShiftSummary[];
|
||||||
scope: "all" | "self";
|
scope: "all" | "self";
|
||||||
|
/** Admin scope only: every operator that has a shift — feeds the filter dropdown. */
|
||||||
|
operators?: string[];
|
||||||
}> {
|
}> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params.operator) qs.set("operator", params.operator);
|
if (params.operator) qs.set("operator", params.operator);
|
||||||
|
|||||||
+45
-10
@@ -13,9 +13,42 @@
|
|||||||
exposed as utilities (night-*, ink-*, paper-*, flag/amber/green/blue, the
|
exposed as utilities (night-*, ink-*, paper-*, flag/amber/green/blue, the
|
||||||
spacing/type/shadow scales) for new work.
|
spacing/type/shadow scales) for new work.
|
||||||
|
|
||||||
Offline appliance: NO webfont @import (no network at runtime). Goldplay (the
|
Offline appliance: NO webfont @import (no network at runtime). The primary
|
||||||
TRM display face) is not self-hosted yet — display/heading text falls back to
|
face is Chakra Petch, SELF-HOSTED from public/fonts/chakra-petch (SIL OFL,
|
||||||
a clean sans stack; wire local Goldplay @font-face here if it's wanted. */
|
license alongside the files) — latin subset only (covers en + sq ë/ç), the
|
||||||
|
weights the UI actually uses (400/600/700 + 400 italic). Not a true
|
||||||
|
monospace: it stays FIRST in --font-mono for the look, with the real mono
|
||||||
|
stack behind it as fallback; .num/.tabular still request tabular figures. */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Chakra Petch";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/fonts/chakra-petch/chakra-petch-latin-400.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Chakra Petch";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/fonts/chakra-petch/chakra-petch-latin-600.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Chakra Petch";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/fonts/chakra-petch/chakra-petch-latin-700.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Chakra Petch";
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/fonts/chakra-petch/chakra-petch-latin-400-italic.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
TERMINAL ACCENTS — aligned onto TRM's exact values.
|
TERMINAL ACCENTS — aligned onto TRM's exact values.
|
||||||
@@ -92,13 +125,15 @@
|
|||||||
--color-viz-8: #5a5a53;
|
--color-viz-8: #5a5a53;
|
||||||
|
|
||||||
/* ---------- TYPE — families ---------- */
|
/* ---------- TYPE — families ---------- */
|
||||||
/* Mono is the booth's primary face (data-dense, tabular). Display/UI fall
|
/* Chakra Petch (self-hosted, see @font-face above) is the booth's primary
|
||||||
back to a clean sans (Goldplay not self-hosted — see header note). */
|
face everywhere — it leads every stack so headings, body, and the
|
||||||
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SFMono-Regular",
|
`font-mono` chrome all render with it; the stacks behind it are the
|
||||||
"Menlo", "Consolas", monospace;
|
pre-2026-07-05 fallbacks for glyphs outside the latin subset. */
|
||||||
--font-display: "Goldplay", "Helvetica Neue", Arial, sans-serif;
|
--font-mono: "Chakra Petch", "JetBrains Mono", "IBM Plex Mono", ui-monospace,
|
||||||
--font-ui: "Goldplay", "Helvetica Neue", Arial, sans-serif;
|
"SFMono-Regular", "Menlo", "Consolas", monospace;
|
||||||
--font-body: "Inter", "Helvetica Neue", Arial, sans-serif;
|
--font-display: "Chakra Petch", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-ui: "Chakra Petch", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-body: "Chakra Petch", "Inter", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
|
||||||
/* ---------- TYPE — scale (TRM, optimised for data density) ---------- */
|
/* ---------- TYPE — scale (TRM, optimised for data density) ---------- */
|
||||||
--text-overline: 11px;
|
--text-overline: 11px;
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// The currencies the booth can price in (ISO 4217). Money is always stored as
|
||||||
|
// integer minor units + one of these codes; the UI offers a closed select rather
|
||||||
|
// than free text so a typo can never publish an unknown currency.
|
||||||
|
export const CURRENCIES = ["ALL", "EUR", "USD"] as const;
|
||||||
|
|
||||||
|
/** The select options: the known set, plus the current value when it's some
|
||||||
|
* historical code outside it (so an old record still displays + round-trips). */
|
||||||
|
export function currencyOptions(current: string): string[] {
|
||||||
|
const cur = current.trim().toUpperCase();
|
||||||
|
return cur && !CURRENCIES.includes(cur as (typeof CURRENCIES)[number]) ? [...CURRENCIES, cur] : [...CURRENCIES];
|
||||||
|
}
|
||||||
+26
-16
@@ -319,25 +319,30 @@ export const en: Catalog = {
|
|||||||
bandDuration: "Band duration",
|
bandDuration: "Band duration",
|
||||||
hoursUnit: "hours",
|
hoursUnit: "hours",
|
||||||
egHours: "e.g. 2",
|
egHours: "e.g. 2",
|
||||||
pricePerIncrement: "Price / increment",
|
pricePerIncrement: "Price / increment (per hour)",
|
||||||
thereafter: "thereafter (open-ended)",
|
thereafter: "thereafter (open-ended)",
|
||||||
remove: "Remove",
|
remove: "Remove",
|
||||||
addBlock: "+ Add block",
|
addBlock: "+ Add block",
|
||||||
publishNewVersion: "Publish new version",
|
publishNewVersion: "Publish new version",
|
||||||
publishing: "Publishing…",
|
publishing: "Publishing…",
|
||||||
|
versionNamePh: "Version name (optional), e.g. Summer 2026",
|
||||||
publishedOk: "New tariff version published — it's now the active rate.",
|
publishedOk: "New tariff version published — it's now the active rate.",
|
||||||
defaultCard: "Base rate (always active)",
|
defaultCard: "Base rate (always active)",
|
||||||
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
|
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
|
||||||
modeLadder: "Hourly ladder",
|
modeLadder: "Hourly ladder",
|
||||||
modeFlat: "Flat price",
|
modeFlat: "Flat price / hour",
|
||||||
modeStepped: "By duration (up-to)",
|
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:
|
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.",
|
"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",
|
stepUpTo: "Up to",
|
||||||
stepTotal: "Total price",
|
stepTotal: "Total price",
|
||||||
addStep: "+ Add row",
|
addStep: "+ Add row",
|
||||||
steppedTiersConflict:
|
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",
|
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.",
|
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",
|
tierName: "Name",
|
||||||
@@ -513,21 +518,26 @@ export const en: Catalog = {
|
|||||||
lab: {
|
lab: {
|
||||||
title: "Tariff Lab",
|
title: "Tariff Lab",
|
||||||
intro:
|
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.",
|
"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.",
|
||||||
loadTicket: "Load from a real ticket",
|
drafts: "Lab tariffs",
|
||||||
loadTicketPh: "Ticket number / identity",
|
newDraft: "New draft",
|
||||||
load: "Load",
|
activeTariff: "Active tariff",
|
||||||
loaded: "Loaded session {{id}}",
|
published: "Published versions",
|
||||||
tariffVersion: "Tariff version",
|
noDrafts: "No lab tariffs yet — create a draft to experiment.",
|
||||||
activeVersion: "Active version (current)",
|
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",
|
entered: "Entered",
|
||||||
asOf: "As of (now/exit)",
|
exit: "Exit",
|
||||||
now: "Now",
|
now: "Now",
|
||||||
category: "Category",
|
|
||||||
categoryPh: "e.g. bus (blank = car)",
|
|
||||||
payment: "Payment",
|
|
||||||
paid: "paid",
|
|
||||||
graceMin: "grace (min)",
|
|
||||||
price: "Compute price",
|
price: "Compute price",
|
||||||
pricing: "Pricing…",
|
pricing: "Pricing…",
|
||||||
outcome: "Outcome",
|
outcome: "Outcome",
|
||||||
|
|||||||
+26
-16
@@ -322,25 +322,30 @@ export const sq = {
|
|||||||
bandDuration: "Kohëzgjatja e brezit",
|
bandDuration: "Kohëzgjatja e brezit",
|
||||||
hoursUnit: "orë",
|
hoursUnit: "orë",
|
||||||
egHours: "p.sh. 2",
|
egHours: "p.sh. 2",
|
||||||
pricePerIncrement: "Çmimi / interval",
|
pricePerIncrement: "Çmimi / interval (orë)",
|
||||||
thereafter: "më pas (i hapur)",
|
thereafter: "më pas (i hapur)",
|
||||||
remove: "Hiq",
|
remove: "Hiq",
|
||||||
addBlock: "+ Shto bllok",
|
addBlock: "+ Shto bllok",
|
||||||
publishNewVersion: "Publiko version të ri",
|
publishNewVersion: "Publiko version të ri",
|
||||||
publishing: "Duke publikuar…",
|
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.",
|
publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.",
|
||||||
defaultCard: "Tarifa bazë (gjithmonë 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.",
|
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",
|
modeLadder: "Shkallë orësh",
|
||||||
modeFlat: "Çmim fiks",
|
modeFlat: "Çmim fiks / orë",
|
||||||
modeStepped: "Sipas kohëzgjatjes (deri-në)",
|
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:
|
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.",
|
"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ë",
|
stepUpTo: "Deri në",
|
||||||
stepTotal: "Çmimi total",
|
stepTotal: "Çmimi total",
|
||||||
addStep: "+ Shto rresht",
|
addStep: "+ Shto rresht",
|
||||||
steppedTiersConflict:
|
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",
|
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ë.",
|
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",
|
tierName: "Emri",
|
||||||
@@ -525,21 +530,26 @@ export const sq = {
|
|||||||
lab: {
|
lab: {
|
||||||
title: "Lab Tarife",
|
title: "Lab Tarife",
|
||||||
intro:
|
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.",
|
"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.",
|
||||||
loadTicket: "Ngarko nga një biletë reale",
|
drafts: "Tarifa laboratori",
|
||||||
loadTicketPh: "Numri i biletës / identiteti",
|
newDraft: "Draft i ri",
|
||||||
load: "Ngarko",
|
activeTariff: "Tarifa aktive",
|
||||||
loaded: "U ngarkua sesioni {{id}}",
|
published: "Versione të publikuara",
|
||||||
tariffVersion: "Versioni i tarifës",
|
noDrafts: "Ende pa tarifa laboratori — krijo një draft për të eksperimentuar.",
|
||||||
activeVersion: "Versioni aktiv (i tanishëm)",
|
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",
|
entered: "Hyrja",
|
||||||
asOf: "Deri më (tani/dalja)",
|
exit: "Dalja",
|
||||||
now: "Tani",
|
now: "Tani",
|
||||||
category: "Kategoria",
|
|
||||||
categoryPh: "p.sh. bus (bosh = makinë)",
|
|
||||||
payment: "Pagesa",
|
|
||||||
paid: "u pagua",
|
|
||||||
graceMin: "afati (min)",
|
|
||||||
price: "Llogarit çmimin",
|
price: "Llogarit çmimin",
|
||||||
pricing: "Duke llogaritur…",
|
pricing: "Duke llogaritur…",
|
||||||
outcome: "Rezultati",
|
outcome: "Rezultati",
|
||||||
|
|||||||
+36
-14
@@ -117,9 +117,10 @@ function SetupLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Subscriptions layout — a standalone top-level section (its own header nav entry),
|
/** Subscriptions layout — a standalone top-level section (its own header nav entry),
|
||||||
* with tabs for the subscriber catalog, the plan catalog, and the tariff lab. Each
|
* with tabs for the subscriber catalog and the plan catalog. Each tab is a gated
|
||||||
* tab is a gated child route; an operator with only subscription:read sees just the
|
* child route; an operator with only subscription:read sees just the first tab.
|
||||||
* first tab. */
|
* (The tariff lab moved to /setup/tariff/lab, 2026-07-05 — it tests the tariff, so
|
||||||
|
* it lives with the tariff.) */
|
||||||
function SubscriptionsLayout() {
|
function SubscriptionsLayout() {
|
||||||
const { user } = rootRoute.useRouteContext();
|
const { user } = rootRoute.useRouteContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -129,7 +130,21 @@ function SubscriptionsLayout() {
|
|||||||
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
|
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
|
||||||
{show("subscription:read") && <SetupTab to="/subscriptions" label={t("nav.subscriptions")} exact />}
|
{show("subscription:read") && <SetupTab to="/subscriptions" label={t("nav.subscriptions")} exact />}
|
||||||
{show("subscription:plan") && <SetupTab to="/subscriptions/plans" label={t("nav.plans")} />}
|
{show("subscription:plan") && <SetupTab to="/subscriptions/plans" label={t("nav.plans")} />}
|
||||||
{show("tariff:read") && <SetupTab to="/subscriptions/tariff-lab" label={t("nav.tariffLab")} />}
|
</nav>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tariff layout — the rate-card hub under Setup: the composer (index) and the
|
||||||
|
* pricing LAB as sub-tabs. One tariff:read gate on the parent covers both. */
|
||||||
|
function TariffLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<nav className="mb-2 flex flex-wrap items-center gap-1 border-b border-term-border">
|
||||||
|
<SetupTab to="/setup/tariff" label={t("nav.tariff")} exact />
|
||||||
|
<SetupTab to="/setup/tariff/lab" label={t("nav.tariffLab")} />
|
||||||
</nav>
|
</nav>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
@@ -519,7 +534,10 @@ const legacyRedirects = (
|
|||||||
["/shift", "/shifts"],
|
["/shift", "/shifts"],
|
||||||
["/setup/subscriptions", "/subscriptions"],
|
["/setup/subscriptions", "/subscriptions"],
|
||||||
["/setup/plans", "/subscriptions/plans"],
|
["/setup/plans", "/subscriptions/plans"],
|
||||||
["/setup/tariff-lab", "/subscriptions/tariff-lab"],
|
// The tariff lab bounced twice: /setup/tariff-lab → /subscriptions/tariff-lab
|
||||||
|
// (2026-06-21) → /setup/tariff/lab (2026-07-05, back with the tariff it tests).
|
||||||
|
["/setup/tariff-lab", "/setup/tariff/lab"],
|
||||||
|
["/subscriptions/tariff-lab", "/setup/tariff/lab"],
|
||||||
["/setup/shifts", "/shifts"],
|
["/setup/shifts", "/shifts"],
|
||||||
["/setup/reports", "/reports"],
|
["/setup/reports", "/reports"],
|
||||||
] as const
|
] as const
|
||||||
@@ -633,8 +651,20 @@ const tariffRoute = createRoute({
|
|||||||
getParentRoute: () => setupRoute,
|
getParentRoute: () => setupRoute,
|
||||||
path: "tariff",
|
path: "tariff",
|
||||||
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
||||||
|
component: TariffLayout,
|
||||||
|
});
|
||||||
|
const tariffComposerRoute = createRoute({
|
||||||
|
getParentRoute: () => tariffRoute,
|
||||||
|
path: "/",
|
||||||
component: () => <TariffComposer />,
|
component: () => <TariffComposer />,
|
||||||
});
|
});
|
||||||
|
// The tariff LAB — lives with the tariff it tests (moved from /subscriptions,
|
||||||
|
// 2026-07-05). The parent's tariff:read gate covers it.
|
||||||
|
const tariffLabRoute = createRoute({
|
||||||
|
getParentRoute: () => tariffRoute,
|
||||||
|
path: "lab",
|
||||||
|
component: () => <TariffLab />,
|
||||||
|
});
|
||||||
|
|
||||||
// --- /subscriptions — a standalone top-level section with its own tabs. The catalog
|
// --- /subscriptions — a standalone top-level section with its own tabs. The catalog
|
||||||
// (index), the plan catalog, and the tariff lab live here, not under /setup. ---
|
// (index), the plan catalog, and the tariff lab live here, not under /setup. ---
|
||||||
@@ -651,7 +681,6 @@ const subscriptionsIndexRoute = createRoute({
|
|||||||
beforeLoad: ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
if (can(context.user, "subscription:read")) return;
|
if (can(context.user, "subscription:read")) return;
|
||||||
if (can(context.user, "subscription:plan")) throw redirect({ to: "/subscriptions/plans" });
|
if (can(context.user, "subscription:plan")) throw redirect({ to: "/subscriptions/plans" });
|
||||||
if (can(context.user, "tariff:read")) throw redirect({ to: "/subscriptions/tariff-lab" });
|
|
||||||
throw redirect({ to: "/booth" });
|
throw redirect({ to: "/booth" });
|
||||||
},
|
},
|
||||||
component: function SubscriptionsRoute() {
|
component: function SubscriptionsRoute() {
|
||||||
@@ -665,12 +694,6 @@ const subscriptionPlansRoute = createRoute({
|
|||||||
beforeLoad: ({ context }) => requirePerm("subscription:plan")(context),
|
beforeLoad: ({ context }) => requirePerm("subscription:plan")(context),
|
||||||
component: () => <SubscriptionPlansManager />,
|
component: () => <SubscriptionPlansManager />,
|
||||||
});
|
});
|
||||||
const tariffLabRoute = createRoute({
|
|
||||||
getParentRoute: () => subscriptionsRoute,
|
|
||||||
path: "tariff-lab",
|
|
||||||
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
|
||||||
component: () => <TariffLab />,
|
|
||||||
});
|
|
||||||
const siteRoute = createRoute({
|
const siteRoute = createRoute({
|
||||||
getParentRoute: () => setupRoute,
|
getParentRoute: () => setupRoute,
|
||||||
path: "site",
|
path: "site",
|
||||||
@@ -751,11 +774,10 @@ const routeTree = rootRoute.addChildren([
|
|||||||
subscriptionsRoute.addChildren([
|
subscriptionsRoute.addChildren([
|
||||||
subscriptionsIndexRoute,
|
subscriptionsIndexRoute,
|
||||||
subscriptionPlansRoute,
|
subscriptionPlansRoute,
|
||||||
tariffLabRoute,
|
|
||||||
]),
|
]),
|
||||||
setupRoute.addChildren([
|
setupRoute.addChildren([
|
||||||
setupDevicesRoute,
|
setupDevicesRoute,
|
||||||
tariffRoute,
|
tariffRoute.addChildren([tariffComposerRoute, tariffLabRoute]),
|
||||||
siteRoute,
|
siteRoute,
|
||||||
usersRoute,
|
usersRoute,
|
||||||
rolesRoute,
|
rolesRoute,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Tariff-lab drafts (2026-07-05). A mutable scratchpad for the lab: the admin composes
|
||||||
|
-- experimental rate cards here, simulates them against hypothetical stays, and only
|
||||||
|
-- PUBLISHES (normal immutable tariff_versions path) when satisfied. Deliberately mutable —
|
||||||
|
-- a draft prices nothing and signs nothing; experimenting through real publishes would
|
||||||
|
-- churn permanent versions and risk a wrong card going live. See wiki/concepts/tariff.md.
|
||||||
|
CREATE TABLE `tariff_drafts` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`currency` text NOT NULL,
|
||||||
|
`structure` text NOT NULL,
|
||||||
|
`created_by` text,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||||
|
`updated_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Optional name on published tariff versions (2026-07-05). The lab's draft workflow gave
|
||||||
|
-- rate cards human names; published versions were only tellable apart by effective date +
|
||||||
|
-- UUID prefix. The name is stamped at publish (carried from the lab draft, or typed in the
|
||||||
|
-- composer) and is immutable like the rest of the row. Nullable — old versions and unnamed
|
||||||
|
-- publishes are fine.
|
||||||
|
ALTER TABLE `tariff_versions` ADD `name` text;
|
||||||
@@ -148,6 +148,20 @@
|
|||||||
"when": 1781886300000,
|
"when": 1781886300000,
|
||||||
"tag": "0020_entry_presence_bypass",
|
"tag": "0020_entry_presence_bypass",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 21,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781886400000,
|
||||||
|
"tag": "0021_tariff_drafts",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 22,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781886500000,
|
||||||
|
"tag": "0022_tariff_version_name",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -316,6 +316,10 @@ export const tariffs = sqliteTable("tariffs", {
|
|||||||
export const tariffVersions = sqliteTable("tariff_versions", {
|
export const tariffVersions = sqliteTable("tariff_versions", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
tariffId: text("tariff_id").notNull(),
|
tariffId: text("tariff_id").notNull(),
|
||||||
|
// Optional human label ("Winter 2027", carried from the lab draft it was published
|
||||||
|
// from). Stamped at publish, immutable like the rest of the row — versions are
|
||||||
|
// told apart in the UI by name, not UUID prefix.
|
||||||
|
name: text("name"),
|
||||||
// The version is in force from this instant (latest with effectiveFrom ≤ entry wins).
|
// The version is in force from this instant (latest with effectiveFrom ≤ entry wins).
|
||||||
effectiveFrom: text("effective_from").notNull(),
|
effectiveFrom: text("effective_from").notNull(),
|
||||||
// ISO 4217; selectable. Money everywhere is { minorUnits, currency }, never a float.
|
// ISO 4217; selectable. Money everywhere is { minorUnits, currency }, never a float.
|
||||||
@@ -329,6 +333,29 @@ export const tariffVersions = sqliteTable("tariff_versions", {
|
|||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A LAB DRAFT rate card — the tariff-lab scratchpad. MUTABLE by design (the one
|
||||||
|
// exception to "editing publishes a version"): a draft prices nothing and signs
|
||||||
|
// nothing — it exists so the admin can experiment in the lab without churning real
|
||||||
|
// tariff_versions (each publish is permanent; experimenting through publishes would
|
||||||
|
// bury the history in noise and risk a wrong card going live). Publishing a draft
|
||||||
|
// goes through the normal POST /api/tariff/versions path (validated, tz-stamped,
|
||||||
|
// immutable). See wiki/concepts/tariff.md (Tariff Lab).
|
||||||
|
export const tariffDrafts = sqliteTable("tariff_drafts", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
currency: text("currency").notNull(),
|
||||||
|
// Same TariffStructure shape as tariff_versions.structure; validated on save so
|
||||||
|
// the lab can always simulate it.
|
||||||
|
structure: text("structure", { mode: "json" }).notNull().$type<Record<string, unknown>>(),
|
||||||
|
createdBy: text("created_by"),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
});
|
||||||
|
|
||||||
// --- Subscriptions --------------------------------------------------------
|
// --- Subscriptions --------------------------------------------------------
|
||||||
// A subscriber: a known holder who parks on a recurring plan (e.g. 10,000 ALL /
|
// A subscriber: a known holder who parks on a recurring plan (e.g. 10,000 ALL /
|
||||||
// month) instead of paying per stay. Mutable master data; every USE still produces a
|
// month) instead of paying per stay. Mutable master data; every USE still produces a
|
||||||
@@ -513,6 +540,7 @@ export type SetupStateRow = typeof setupState.$inferSelect;
|
|||||||
export type SiteConfigRow = typeof siteConfig.$inferSelect;
|
export type SiteConfigRow = typeof siteConfig.$inferSelect;
|
||||||
export type TariffRow = typeof tariffs.$inferSelect;
|
export type TariffRow = typeof tariffs.$inferSelect;
|
||||||
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
|
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
|
||||||
|
export type TariffDraftRow = typeof tariffDrafts.$inferSelect;
|
||||||
export type SubscriptionRow = typeof subscriptions.$inferSelect;
|
export type SubscriptionRow = typeof subscriptions.$inferSelect;
|
||||||
export type SubscriptionPlanRow = typeof subscriptionPlans.$inferSelect;
|
export type SubscriptionPlanRow = typeof subscriptionPlans.$inferSelect;
|
||||||
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
|
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
|
||||||
|
|||||||
@@ -606,8 +606,9 @@ export interface TariffWindow {
|
|||||||
readonly toHour?: string;
|
readonly toHour?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A V2 pricing card: a flat rate OR a stepped block ladder (with its own cap).
|
/** A V2 pricing card: a per-increment flat rate, a block ladder, a stepped table
|
||||||
* `flatMinor` and `blocks` are mutually exclusive. The defaultCard has no window. */
|
* (defaultCard only), or a whole-window package (windowed cards only). The pricing
|
||||||
|
* fields are mutually exclusive — exactly one. The defaultCard has no window. */
|
||||||
export interface TariffCard {
|
export interface TariffCard {
|
||||||
/** Human label (also the final, deterministic precedence tiebreak). */
|
/** Human label (also the final, deterministic precedence tiebreak). */
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
@@ -617,13 +618,21 @@ export interface TariffCard {
|
|||||||
readonly category?: string;
|
readonly category?: string;
|
||||||
/** Wall-clock activation window. Absent only on the defaultCard (always active). */
|
/** Wall-clock activation window. Absent only on the defaultCard (always active). */
|
||||||
readonly window?: TariffWindow;
|
readonly window?: TariffWindow;
|
||||||
/** Flat price per billing increment (mutually exclusive with `blocks`/`steps`). */
|
/** Flat price PER BILLING INCREMENT (an hourly flat rate at increment 60) —
|
||||||
|
* mutually exclusive with the other pricing fields. NOT a whole-stay price;
|
||||||
|
* for "one total for the whole window" use `packageMinor`. */
|
||||||
readonly flatMinor?: number;
|
readonly flatMinor?: number;
|
||||||
/** Marginal block ladder (mutually exclusive with `flatMinor`/`steps`); last open-ended. */
|
/** Marginal block ladder (mutually exclusive with the other pricing fields); last open-ended. */
|
||||||
readonly blocks?: readonly TariffBlock[];
|
readonly blocks?: readonly TariffBlock[];
|
||||||
/** STEPPED ("up-to") total-by-duration table (mutually exclusive with `flatMinor`/
|
/** STEPPED ("up-to") total-by-duration table (mutually exclusive with the other
|
||||||
* `blocks`). The top tier's total is this card's per-day price. */
|
* pricing fields; defaultCard only). The top tier's total is the per-day price. */
|
||||||
readonly steps?: readonly TariffStep[];
|
readonly steps?: readonly TariffStep[];
|
||||||
|
/** WINDOW PACKAGE (windowed cards only, 2026-07-05): ONE total charged per
|
||||||
|
* contiguous occurrence of this card winning increments — e.g. "any presence in
|
||||||
|
* the 20:00–07:00 window = 400, leave earlier and it's still 400". Any touch of
|
||||||
|
* the window pays the full package; a stay spanning two nights pays it twice
|
||||||
|
* (once per occurrence). Mutually exclusive with the other pricing fields. */
|
||||||
|
readonly packageMinor?: number;
|
||||||
/** Cap per rolling 24h for THIS card's ladder. Only the defaultCard's cap governs
|
/** Cap per rolling 24h for THIS card's ladder. Only the defaultCard's cap governs
|
||||||
* a mixed day (see computeFeeV2). null = no cap. */
|
* a mixed day (see computeFeeV2). null = no cap. */
|
||||||
readonly dailyCapMinor?: number | null;
|
readonly dailyCapMinor?: number | null;
|
||||||
@@ -856,18 +865,30 @@ function computeFeeV2(
|
|||||||
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!);
|
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!);
|
||||||
|
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
// WINDOW-PACKAGE tracking (2026-07-05): a `packageMinor` card charges ONE total per
|
||||||
|
// contiguous run of increments it wins (an "occurrence" — e.g. one night), however
|
||||||
|
// little of the window the car actually used. The tracker survives the day-segment
|
||||||
|
// loop so a night run crossing the rolling-24h boundary charges once, not twice;
|
||||||
|
// the charge lands in the segment where the occurrence starts (that day's cap
|
||||||
|
// applies to it). A stay touching the window on two different nights = two
|
||||||
|
// occurrences = two charges.
|
||||||
|
let prevWinner: TariffCard | null = null;
|
||||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||||
const segEnd = Math.min(segStart + DAY, minutes);
|
const segEnd = Math.min(segStart + DAY, minutes);
|
||||||
let segFee = 0;
|
let segFee = 0;
|
||||||
for (let within = segStart; within < segEnd; within += inc) {
|
for (let within = segStart; within < segEnd; within += inc) {
|
||||||
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
|
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
|
||||||
const card = selectCard(cards, wall);
|
const card = selectCard(cards, wall);
|
||||||
if (card.flatMinor != null) {
|
if (card.packageMinor != null) {
|
||||||
|
// First increment of a new occurrence pays the package; the rest ride free.
|
||||||
|
if (prevWinner !== card) segFee += card.packageMinor;
|
||||||
|
} else if (card.flatMinor != null) {
|
||||||
segFee += card.flatMinor;
|
segFee += card.flatMinor;
|
||||||
} else {
|
} else {
|
||||||
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
|
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
|
||||||
segFee += rateAt(card.blocks ?? [], within - segStart);
|
segFee += rateAt(card.blocks ?? [], within - segStart);
|
||||||
}
|
}
|
||||||
|
prevWinner = card;
|
||||||
}
|
}
|
||||||
if (dayCap != null) segFee = Math.min(segFee, dayCap);
|
if (dayCap != null) segFee = Math.min(segFee, dayCap);
|
||||||
total += segFee;
|
total += segFee;
|
||||||
@@ -983,9 +1004,17 @@ function validateCard(c: Partial<TariffCard> | undefined, label: string, isDefau
|
|||||||
const hasFlat = c.flatMinor != null;
|
const hasFlat = c.flatMinor != null;
|
||||||
const hasBlocks = c.blocks != null;
|
const hasBlocks = c.blocks != null;
|
||||||
const hasStepTable = c.steps != null;
|
const hasStepTable = c.steps != null;
|
||||||
const modes = [hasFlat, hasBlocks, hasStepTable].filter(Boolean).length;
|
const hasPackage = c.packageMinor != null;
|
||||||
|
const modes = [hasFlat, hasBlocks, hasStepTable, hasPackage].filter(Boolean).length;
|
||||||
if (modes !== 1) {
|
if (modes !== 1) {
|
||||||
errs.push(`${label} must set exactly one of flatMinor, blocks, or steps`);
|
errs.push(`${label} must set exactly one of flatMinor, blocks, steps, or packageMinor`);
|
||||||
|
} else if (hasPackage) {
|
||||||
|
// A whole-window package needs a window to be an occurrence of — meaningless on
|
||||||
|
// the always-active defaultCard (a base "one price per stay/day" is a 1-row
|
||||||
|
// stepped table there). See wiki/concepts/tariff-time-tiers.md.
|
||||||
|
if (isDefault) errs.push(`${label}: packageMinor (whole-window package) is only allowed on a windowed card`);
|
||||||
|
nonNegInt(c.packageMinor, `${label}.packageMinor`, errs);
|
||||||
|
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor does not apply to a window package (the package IS the window's total)`);
|
||||||
} else if (hasFlat) {
|
} else if (hasFlat) {
|
||||||
nonNegInt(c.flatMinor, `${label}.flatMinor`, errs);
|
nonNegInt(c.flatMinor, `${label}.flatMinor`, errs);
|
||||||
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor applies to a block ladder, not a flat rate`);
|
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor applies to a block ladder, not a flat rate`);
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ describe("validate V2", () => {
|
|||||||
});
|
});
|
||||||
it("rejects a card with both flat and blocks", () => {
|
it("rejects a card with both flat and blocks", () => {
|
||||||
const errs = validateTariffStructure({ ...base, defaultCard: { name: "d", priority: 0, flatMinor: 100, blocks: ladder(100) } });
|
const errs = validateTariffStructure({ ...base, defaultCard: { name: "d", priority: 0, flatMinor: 100, blocks: ladder(100) } });
|
||||||
expect(errs).toContain("defaultCard must set exactly one of flatMinor, blocks, or steps");
|
expect(errs).toContain("defaultCard must set exactly one of flatMinor, blocks, steps, or packageMinor");
|
||||||
});
|
});
|
||||||
it("rejects defaultCard with a window", () => {
|
it("rejects defaultCard with a window", () => {
|
||||||
const errs = validateTariffStructure({ ...base, defaultCard: { ...okDefault, window: { dow: [1] } } });
|
const errs = validateTariffStructure({ ...base, defaultCard: { ...okDefault, window: { dow: [1] } } });
|
||||||
@@ -383,3 +383,57 @@ describe("stepped (up-to) pricing — owner matrix", () => {
|
|||||||
expect(validateTariffStructure(capped).some((e) => /dailyCap/i.test(e))).toBe(true);
|
expect(validateTariffStructure(capped).some((e) => /dailyCap/i.test(e))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// (j) WINDOW PACKAGE (packageMinor) — "any presence in the window = one total".
|
||||||
|
// Charged once per contiguous occurrence of the card winning increments; any touch
|
||||||
|
// pays the full package; a run crossing the rolling-24h boundary charges ONCE.
|
||||||
|
// Base: open-ended 100/h ladder (minor 10000). Night card: 20:00–07:00 = 40000.
|
||||||
|
// tz Europe/Tirane (summer = UTC+2); windows carry no dow so weekday is irrelevant.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("V2 window package (whole-window total)", () => {
|
||||||
|
const pkg: TariffStructureV2 = {
|
||||||
|
version: 2,
|
||||||
|
tz: "Europe/Tirane",
|
||||||
|
gracePeriodEntryMin: 0,
|
||||||
|
incrementMin: 60,
|
||||||
|
lostTicketMinor: 0,
|
||||||
|
gracePeriodExitMin: 5,
|
||||||
|
overstay: "reprice",
|
||||||
|
defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 10000 }], dailyCapMinor: null },
|
||||||
|
windowedCards: [{ name: "night", priority: 10, packageMinor: 40000, window: { fromHour: "20:00", toHour: "07:00" } }],
|
||||||
|
};
|
||||||
|
const fee = (enter: string, exit: string) => computeFee(enter, exit, pkg);
|
||||||
|
|
||||||
|
it("leave early, the package stays: 22:00→23:30 (90 min in-window) = 40000", () => {
|
||||||
|
expect(fee("2026-06-16T22:00:00+02:00", "2026-06-16T23:30:00+02:00")).toBe(40000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("any touch pays full: 06:00→06:45 (45 min at the window's tail) = 40000", () => {
|
||||||
|
expect(fee("2026-06-16T06:00:00+02:00", "2026-06-16T06:45:00+02:00")).toBe(40000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments inside one occurrence add nothing: a full night 20:00→07:00 = 40000", () => {
|
||||||
|
expect(fee("2026-06-16T20:00:00+02:00", "2026-06-17T07:00:00+02:00")).toBe(40000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mixed 29h stay: two occurrences + day hours; the run over the rolling-day boundary charges ONCE", () => {
|
||||||
|
// Enter Tue 02:00, exit Wed 07:00 (29 increments). Occurrence 1: 02:00–06:00 (the
|
||||||
|
// overnight window's tail) = 40000. Base: 07:00–19:00 = 13 × 10000. Occurrence 2:
|
||||||
|
// Tue 20:00 → Wed 06:00 — CROSSES the rolling-24h boundary (Wed 02:00) but is one
|
||||||
|
// contiguous run → one 40000, not two. Total 40000 + 130000 + 40000 = 210000.
|
||||||
|
expect(fee("2026-06-16T02:00:00+02:00", "2026-06-17T07:00:00+02:00")).toBe(210000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates: package on the defaultCard is rejected", () => {
|
||||||
|
const bad = { ...pkg, defaultCard: { name: "d", priority: 0, packageMinor: 40000 } };
|
||||||
|
expect(validateTariffStructure(bad).some((e) => /only allowed on a windowed card/.test(e))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates: package is exclusive with other pricing fields + the cap", () => {
|
||||||
|
const both = { ...pkg, windowedCards: [{ name: "n", priority: 1, packageMinor: 1, flatMinor: 1, window: { dow: [1] } }] };
|
||||||
|
expect(validateTariffStructure(both).some((e) => /exactly one of/.test(e))).toBe(true);
|
||||||
|
const capped = { ...pkg, windowedCards: [{ name: "n", priority: 1, packageMinor: 1, dailyCapMinor: 100, window: { dow: [1] } }] };
|
||||||
|
expect(validateTariffStructure(capped).some((e) => /dailyCapMinor does not apply to a window package/.test(e))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, domain, business, pricing, design]
|
tags: [parking, domain, business, pricing, design]
|
||||||
sources: [parksql2017-legacy-schema]
|
sources: [parksql2017-legacy-schema]
|
||||||
updated: 2026-06-18
|
updated: 2026-07-05
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -140,7 +140,22 @@ case stays one rate card; tiers are opt-in.
|
|||||||
`DEFAULT_VEHICLE_CATEGORY` in `@parking/shared`). Per-relay capture (a "bus lane") is the future
|
`DEFAULT_VEHICLE_CATEGORY` in `@parking/shared`). Per-relay capture (a "bus lane") is the future
|
||||||
seam, mirroring per-relay direction.
|
seam, mirroring per-relay direction.
|
||||||
- **Flat rate** is a first-class card body (`flatMinor`, mutually exclusive with `blocks`). A flat V1
|
- **Flat rate** is a first-class card body (`flatMinor`, mutually exclusive with `blocks`). A flat V1
|
||||||
is published as a single open-ended block (V1 has no flat field).
|
is published as a single open-ended block (V1 has no flat field). ⚠ `flatMinor` is **per billing
|
||||||
|
increment** (an hourly flat rate at increment 60) — NOT a whole-stay/whole-window price. This was
|
||||||
|
misread in the field (park-buzi published a "night 400" believing it covered the night; it billed
|
||||||
|
400/h, 2026-07-05) — the UI now labels it "Flat price / hour" and the whole-window need got its own
|
||||||
|
mode:
|
||||||
|
- **WINDOW PACKAGE (`packageMinor`, 2026-07-05 — windowed cards only).** "Any presence in this
|
||||||
|
window = ONE total" (the real night rate: 20:00–07:00 = 400, leave earlier and it's still 400).
|
||||||
|
Decisions (operator, 2026-07-05): charged **once per occurrence** (a stay touching two nights pays
|
||||||
|
twice); **any touch pays full** (an 06:30 arrival before the 07:00 close pays the whole package —
|
||||||
|
package pricing's accepted sharp edge); **not offered on the base card** (a base "one price per
|
||||||
|
day" is a 1-row up-to table — no duplicate concept). Engine: one charge per **contiguous run of
|
||||||
|
increments the card wins**, tracked across rolling-day segments so a night crossing the 24h
|
||||||
|
boundary charges once; out-of-window increments price by the base rate as usual; the charge lands
|
||||||
|
in the day segment where the occurrence starts (that day's default-card cap applies). Mutually
|
||||||
|
exclusive with flat/blocks/steps + no per-card cap (the package IS the window's total); validator
|
||||||
|
enforces both and the composer offers the mode only on tier cards.
|
||||||
- **UI** (`TariffComposer.tsx`): default card **front-and-centre** (flat/ladder toggle + cap); tiers
|
- **UI** (`TariffComposer.tsx`): default card **front-and-centre** (flat/ladder toggle + cap); tiers
|
||||||
under a collapsed **"Advanced: time & seasonal tiers"** disclosure (window builder — dow checkboxes,
|
under a collapsed **"Advanced: time & seasonal tiers"** disclosure (window builder — dow checkboxes,
|
||||||
optional date range, optional hour range with an overnight hint; category; priority; flat/ladder
|
optional date range, optional hour range with an overnight hint; category; priority; flat/ladder
|
||||||
|
|||||||
+32
-12
@@ -197,24 +197,44 @@ The admin authors the rate card at runtime — no hand-seeding:
|
|||||||
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
|
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
|
||||||
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
|
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
|
||||||
|
|
||||||
### Tariff Lab (simulator, as-built 2026-06-20)
|
### Tariff Lab (simulator, as-built 2026-06-20; drafts redesign 2026-07-05)
|
||||||
|
|
||||||
The tariff engine is a **pure function of time**, but you could previously only *exercise* it by
|
The tariff engine is a **pure function of time**, but you could previously only *exercise* it by
|
||||||
waiting (the only clock the booth reads is the real wall-clock). The **Tariff Lab** closes that gap:
|
waiting (the only clock the booth reads is the real wall-clock). The **Tariff Lab** closes that gap:
|
||||||
price a session at **any** instant against **any** tariff version in seconds.
|
compose an **experimental rate card**, price hypothetical stays against it in seconds, and publish
|
||||||
|
only when satisfied.
|
||||||
|
|
||||||
- **API** (`apps/server/src/routes/tariffs.ts`, `tariff:read` — admins always have it; available
|
- **Drafts (`tariff_drafts` table, 2026-07-05).** The lab's rate cards live in their own **mutable**
|
||||||
on-site too, useful to quote a customer dispute): `POST /api/tariff/simulate` prices a hypothetical
|
table — the one deliberate exception to "editing publishes a version". Rationale (operator ask,
|
||||||
|
2026-07-05): experimenting by publishing real versions churns the immutable history with noise AND
|
||||||
|
risks a wrong card being live while the admin iterates ("we risk taking tickets with a grossly
|
||||||
|
wrong version"). A draft prices nothing and signs nothing, so mutability is safe; the ONLY way a
|
||||||
|
draft affects a customer is publication through the normal `POST /api/tariff/versions` path
|
||||||
|
(validated, tz-stamped, immutable, effectiveFrom-guarded). Drafts are **validated + tz-stamped on
|
||||||
|
save exactly like a publish**, so a saved draft can always be simulated and "Publish" can never
|
||||||
|
fail on a card that saved fine.
|
||||||
|
- **API** (`apps/server/src/routes/tariffs.ts`): `GET/POST/PUT/DELETE /api/tariff/drafts[...]`
|
||||||
|
(list `tariff:read`; mutations `tariff:update`). `POST /api/tariff/simulate` prices a hypothetical
|
||||||
session — body `{enteredAt, asOf, payments[], category?, tariffVersionId? | structure?}` — and
|
session — body `{enteredAt, asOf, payments[], category?, tariffVersionId? | structure?}` — and
|
||||||
returns the full `priceSession` outcome plus a **duration curve** (fee from entry at 30m…3d, so you
|
returns the full `priceSession` outcome plus a **duration curve** (fee from entry at 30m…3d, so you
|
||||||
SEE where the daily cap flattens or a window shifts). `GET /api/tariff/simulate/session/:identity`
|
SEE where the daily cap flattens or a window shifts); the lab passes a draft's stored `structure`
|
||||||
prefills from a **real ledger session** (entry + payments + the version frozen at entry). Both are
|
inline. `GET /api/tariff/simulate/session/:identity` (prefill from a real ledger session) still
|
||||||
**read-only — no ledger writes.**
|
exists API-side but the UI no longer uses it. All **read-only — no ledger writes.**
|
||||||
- **UI** (`apps/web/src/TariffLab.tsx`, Setup → "Tariff Lab"): pick a version (active or any
|
- **UI** (`apps/web/src/TariffLab.tsx`, Setup → Tariff → "Tariff Lab" tab): a **sidebar lists every
|
||||||
historical), set entry / "as of" times, an optional payment (with its grace), and a category; or
|
lab draft AND the full published history** (active card first, then older immutable versions) —
|
||||||
"Load" a real ticket to re-evaluate it at any moment. Shows amount due, billed period, overstay/
|
click any to price against it (drafts send their structure inline; published versions go by
|
||||||
settled state, and the curve. Prices via the same `priceSession` the booth uses (verified: a real
|
`tariffVersionId`). Published versions carry an **optional name** (`tariff_versions.name`,
|
||||||
overstay ticket reads identically in the lab and the booth). See [[booth-exit-flow]] (overstay).
|
migration 0022, stamped at publish and immutable like the row): publishing a draft carries the
|
||||||
|
draft's name onto the version, and the composer page grew an optional version-name field — so
|
||||||
|
history reads "Winter 2027", not UUID prefixes. The main pane is a pure
|
||||||
|
**entry/exit** pair (the 2026-06-20 ticket-loader, payment, and category inputs were dropped in the
|
||||||
|
redesign — the lab is for composing rates, not re-evaluating tickets) plus amount due, billed
|
||||||
|
period, overstay/settled state, and the curve. **"New draft" / "Edit" open the composer form in a
|
||||||
|
modal** — the *same* form the `/setup/tariff` page uses, extracted to
|
||||||
|
`apps/web/src/TariffEditorForm.tsx` (new drafts prefill from the active card). Per-draft
|
||||||
|
**Publish** (confirm prompt) goes through the normal immutable-version path. Prices via the same
|
||||||
|
`priceSession` the booth uses, so the lab and the live booth can never diverge.
|
||||||
|
See [[booth-exit-flow]] (overstay).
|
||||||
|
|
||||||
## The pay-on-foot consequence
|
## The pay-on-foot consequence
|
||||||
|
|
||||||
|
|||||||
+38
@@ -2282,3 +2282,41 @@ ISO-8601 UTC + level names (pinoDbStream hardened to accept both encodings so th
|
|||||||
silently break). Rotation: docker json-file caps in docker-compose.prod.yml resized from 10m×3
|
silently break). Rotation: docker json-file caps in docker-compose.prod.yml resized from 10m×3
|
||||||
(≈30 MB!) to ≈2 months by volume (server 20m×30, vision 20m×10, proxy 10m×5; json-file rotates by
|
(≈30 MB!) to ≈2 months by volume (server 20m×30, vision 20m×10, proxy 10m×5; json-file rotates by
|
||||||
SIZE — time-based isn't a driver feature). app_logs retention default aligned 30→60 days.
|
SIZE — time-based isn't a driver feature). app_logs retention default aligned 30→60 days.
|
||||||
|
|
||||||
|
## [2026-07-05] update | Window-package tariff mode (packageMinor) + honest flat labels
|
||||||
|
|
||||||
|
Tariff-lab verification of a 1,850 ALL bill exposed a field misread: the V2 card "flat price" is
|
||||||
|
PER INCREMENT (400/h), not per window — park-buzi's "night 400" card billed each night hour 400.
|
||||||
|
Built the missing concept on [[tariff-time-tiers]]: `packageMinor`, a whole-window package
|
||||||
|
("any presence in 20:00–07:00 = 400 total"). Operator decisions: per-occurrence repeat (two nights
|
||||||
|
= two charges), any-touch-pays-full, windowed-cards-only (base "price per day" = a 1-row up-to
|
||||||
|
table). Engine charges once per contiguous run of increments the card wins, tracked across
|
||||||
|
rolling-day segments (a night crossing the 24h boundary charges once). Validator: exclusive with
|
||||||
|
flat/blocks/steps, no per-card cap, defaultCard forbidden. Composer offers the mode on tier cards;
|
||||||
|
flat relabeled "Flat price / hour" (sq+en). Also flagged from the same session: windowed-card
|
||||||
|
dailyCapMinor is inert by design (only the base card's cap clamps a day) — park-buzi's weekend
|
||||||
|
card carries a dead 1000 cap. Tests: shared 93 green (6 new), server 283 green.
|
||||||
|
|
||||||
|
## [2026-07-05] update | Tariff Lab redesign: DB-backed drafts, sidebar, composer modal
|
||||||
|
|
||||||
|
The lab previously simulated only against PUBLISHED versions, so experimenting meant publishing —
|
||||||
|
churning the immutable history and risking real tickets pricing against a half-baked card while
|
||||||
|
the admin iterated (operator: "we risk taking tickets with a grossly wrong version"). Redesign on
|
||||||
|
[[tariff]] (Tariff Lab section): new mutable `tariff_drafts` table (migration 0021 — the one
|
||||||
|
deliberate exception to "editing publishes a version"; a draft prices/signs nothing, only the
|
||||||
|
normal publish path makes it real), drafts validated + tz-stamped on save exactly like a publish,
|
||||||
|
CRUD under /api/tariff/drafts (list tariff:read, mutations tariff:update). UI rebuilt: sidebar
|
||||||
|
lists active card + drafts (click to price against), main pane cut to pure entry/exit (ticket
|
||||||
|
loader, payment, category inputs dropped), composer form extracted to TariffEditorForm.tsx and
|
||||||
|
reused in a modal (new drafts prefill from the active card), per-draft Publish with confirm.
|
||||||
|
Simulation passes the draft's stored structure inline to the existing /api/tariff/simulate.
|
||||||
|
Tests: server 288 green (5 new: RBAC, roundtrip, validation, tz-stamp + simulate + publish flow).
|
||||||
|
|
||||||
|
## [2026-07-05] update | Published tariff versions get optional names + lab sidebar lists history
|
||||||
|
|
||||||
|
Follow-up to the lab redesign (same session): the sidebar now also lists the PUBLISHED versions
|
||||||
|
(active first, then the immutable history; click to price against by tariffVersionId), and
|
||||||
|
`tariff_versions` gained a nullable `name` (migration 0022) — stamped at publish, immutable like
|
||||||
|
the row. Publishing a lab draft carries the draft's name onto the version; the composer page grew
|
||||||
|
an optional version-name field (never prefilled — republishing a tweak under last season's name
|
||||||
|
would mislabel history). Details on [[tariff]] (Tariff Lab section).
|
||||||
|
|||||||
Reference in New Issue
Block a user