diff --git a/apps/server/src/routes/shift.ts b/apps/server/src/routes/shift.ts
index 9b0c428..194bc90 100644
--- a/apps/server/src/routes/shift.ts
+++ b/apps/server/src/routes/shift.ts
@@ -63,7 +63,10 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
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
diff --git a/apps/server/src/routes/tariff-drafts.test.ts b/apps/server/src/routes/tariff-drafts.test.ts
new file mode 100644
index 0000000..b55171d
--- /dev/null
+++ b/apps/server/src/routes/tariff-drafts.test.ts
@@ -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);
+ });
+});
diff --git a/apps/server/src/routes/tariffs.ts b/apps/server/src/routes/tariffs.ts
index bf789a0..30ac935 100644
--- a/apps/server/src/routes/tariffs.ts
+++ b/apps/server/src/routes/tariffs.ts
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
-import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
+import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db";
import {
computeFee,
isTariffV2,
@@ -25,10 +25,19 @@ interface PublishBody {
structure: TariffStructure;
/** When this version takes effect (ISO-8601). Defaults to now. */
effectiveFrom?: string;
+ /** Optional human label (e.g. carried from the lab draft being published). */
+ name?: string;
}
const SITE_TARIFF_NAME = "Site tariff";
+/** Body for saving a lab draft (create + update share the shape). */
+interface DraftBody {
+ name: string;
+ currency: string;
+ structure: TariffStructure;
+}
+
/** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write.
* Provide a structure source (one of): `tariffVersionId`, inline `structure`, or
* neither (uses the active version). */
@@ -80,19 +89,14 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise
"/api/tariff/versions",
{ preHandler: writeGuard },
async (req, reply) => {
- const { currency, structure, effectiveFrom } = req.body ?? ({} as PublishBody);
+ const { currency, structure, effectiveFrom, name } = req.body ?? ({} as PublishBody);
if (!currency || typeof currency !== "string" || currency.length < 3) {
return reply.code(400).send({ error: "currency (ISO 4217) required" });
}
// For a windowed (V2) structure, stamp the wall-clock timezone from SITE config
// (not the client) BEFORE validating — so the frozen tz is authoritative and the
// validation that requires tz passes. A V1 (bare) structure is left untouched.
- let toStore: TariffStructure = structure;
- if (structure && isTariffV2(structure)) {
- const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
- const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
- toStore = { ...structure, tz };
- }
+ const toStore = stampSiteTz(structure);
const problems = validateTariffStructure(toStore);
if (problems.length) {
@@ -128,6 +132,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise
const row = {
id,
tariffId,
+ name: typeof name === "string" && name.trim() ? name.trim() : null,
effectiveFrom: effective,
currency,
structure: toStore as unknown as Record,
@@ -230,6 +235,92 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise
},
);
+ // --- 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,
+ 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,
+ updatedAt: new Date().toISOString(),
+ };
+ db.update(tariffDrafts).set(patch).where(eq(tariffDrafts.id, existing.id)).run();
+ return { ...existing, ...patch };
+ },
+ );
+
+ app.delete<{ Params: { id: string } }>(
+ "/api/tariff/drafts/:id",
+ { preHandler: writeGuard },
+ async (req, reply) => {
+ const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get();
+ if (!existing) return reply.code(404).send({ error: "draft not found" });
+ db.delete(tariffDrafts).where(eq(tariffDrafts.id, existing.id)).run();
+ return reply.code(204).send();
+ },
+ );
+
+ /** Validate + normalise a draft save body; tz-stamps V2 structures like a publish. */
+ function parseDraftBody(
+ body: DraftBody | undefined,
+ ): { name: string; currency: string; structure: TariffStructure } | { error: string; problems?: string[] } {
+ const b = body ?? ({} as DraftBody);
+ const name = (b.name ?? "").trim();
+ if (!name) return { error: "name required" };
+ const currency = (b.currency ?? "").trim().toUpperCase();
+ if (currency.length < 3) return { error: "currency (ISO 4217) required" };
+ const structure = stampSiteTz(b.structure);
+ const problems = validateTariffStructure(structure);
+ if (problems.length) return { error: "invalid tariff structure", problems };
+ return { name, currency, structure };
+ }
+
+ /** Stamp a V2 structure's frozen wall-clock timezone from SITE config (never the
+ * client); a V1 (bare) structure passes through untouched. */
+ function stampSiteTz(structure: TariffStructure): TariffStructure {
+ if (structure && isTariffV2(structure)) {
+ const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
+ const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
+ return { ...structure, tz };
+ }
+ return structure;
+ }
+
/** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */
function tariffVersionIdFor(whenIso: string): string | null {
const tariffId = ensureSiteTariff();
diff --git a/apps/server/src/shift-service.test.ts b/apps/server/src/shift-service.test.ts
index ca285b1..c57da65 100644
--- a/apps/server/src/shift-service.test.ts
+++ b/apps/server/src/shift-service.test.ts
@@ -234,4 +234,11 @@ describe("close signs a Z-report; listShifts reads it back", () => {
await shift.open("bob"); await shift.close("bob");
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"]);
+ });
});
diff --git a/apps/server/src/shift-service.ts b/apps/server/src/shift-service.ts
index dde17f3..246cfb5 100644
--- a/apps/server/src/shift-service.ts
+++ b/apps/server/src/shift-service.ts
@@ -177,6 +177,28 @@ export class ShiftService {
* The open shift (no z_report yet) is intentionally excluded — it's not a
* 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();
+ 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[] {
const rows = this.#db
.select()
diff --git a/apps/web/index.html b/apps/web/index.html
index ff9dfef..d276f41 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -4,6 +4,10 @@
+
+
+
Parking System
diff --git a/apps/web/public/fonts/chakra-petch/OFL.txt b/apps/web/public/fonts/chakra-petch/OFL.txt
new file mode 100644
index 0000000..9cee5a5
--- /dev/null
+++ b/apps/web/public/fonts/chakra-petch/OFL.txt
@@ -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.
diff --git a/apps/web/public/fonts/chakra-petch/chakra-petch-latin-400-italic.woff2 b/apps/web/public/fonts/chakra-petch/chakra-petch-latin-400-italic.woff2
new file mode 100644
index 0000000..89fda8b
Binary files /dev/null and b/apps/web/public/fonts/chakra-petch/chakra-petch-latin-400-italic.woff2 differ
diff --git a/apps/web/public/fonts/chakra-petch/chakra-petch-latin-400.woff2 b/apps/web/public/fonts/chakra-petch/chakra-petch-latin-400.woff2
new file mode 100644
index 0000000..f7a6602
Binary files /dev/null and b/apps/web/public/fonts/chakra-petch/chakra-petch-latin-400.woff2 differ
diff --git a/apps/web/public/fonts/chakra-petch/chakra-petch-latin-600.woff2 b/apps/web/public/fonts/chakra-petch/chakra-petch-latin-600.woff2
new file mode 100644
index 0000000..e4d90d5
Binary files /dev/null and b/apps/web/public/fonts/chakra-petch/chakra-petch-latin-600.woff2 differ
diff --git a/apps/web/public/fonts/chakra-petch/chakra-petch-latin-700.woff2 b/apps/web/public/fonts/chakra-petch/chakra-petch-latin-700.woff2
new file mode 100644
index 0000000..de88f23
Binary files /dev/null and b/apps/web/public/fonts/chakra-petch/chakra-petch-latin-700.woff2 differ
diff --git a/apps/web/src/ShiftsHistory.tsx b/apps/web/src/ShiftsHistory.tsx
index 81828c3..e66a4a4 100644
--- a/apps/web/src/ShiftsHistory.tsx
+++ b/apps/web/src/ShiftsHistory.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
-import { useQuery } from "@tanstack/react-query";
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
closeShift,
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,
};
- 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 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).
const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed;
@@ -169,7 +177,14 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
{isAdmin && (
{t("shifts.operator")}
- 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. */}
+
)}
diff --git a/apps/web/src/SubscriptionPlansManager.tsx b/apps/web/src/SubscriptionPlansManager.tsx
index 006882c..9cf36eb 100644
--- a/apps/web/src/SubscriptionPlansManager.tsx
+++ b/apps/web/src/SubscriptionPlansManager.tsx
@@ -14,6 +14,7 @@ import {
type SubscriptionPlan,
} from "./api.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
// operator sells from (so the operator never types a price). Editing a plan PUBLISHES A
@@ -348,7 +349,11 @@ export function SubscriptionPlansManager() {
setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
- setForm((f) => f && { ...f, currency: e.target.value })} />
+
/ {t(PERIOD_KEY[form.period])}
diff --git a/apps/web/src/TariffComposer.tsx b/apps/web/src/TariffComposer.tsx
index 2a1004e..7b24dce 100644
--- a/apps/web/src/TariffComposer.tsx
+++ b/apps/web/src/TariffComposer.tsx
@@ -1,267 +1,23 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
-import {
- ApiError,
- fetchTariff,
- isTariffV2,
- publishTariffVersion,
- type TariffBlock,
- type TariffCard,
- type TariffStep,
- type TariffStructure,
- type TariffState,
-} from "./api.js";
+import { ApiError, fetchTariff, publishTariffVersion, type TariffState } from "./api.js";
+import { TariffEditorForm, emptyForm, formFromActive, toStructure, type FormState } from "./TariffEditorForm.js";
-// Tariff composer — the admin builds + edits the rate card at runtime. Publishing
+// Tariff composer — the admin edits + publishes the LIVE rate card. Publishing
// creates a new IMMUTABLE version (the active card); old versions are kept so past
-// sessions reprice correctly. Amounts are entered in major units (e.g. euros) for
-// usability and converted to integer minor units on submit. See wiki/concepts/tariff.md.
-
-// Editable form mirror of TariffStructure, but money in major-unit strings.
-// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the
-// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes.
-// The LAST block is always open-ended ("thereafter"): its hours field is unused
-// and it has no bound. On submit, per-block hours accumulate into the engine's
-// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null.
-interface BlockForm {
- hours: string; // duration of THIS band, in hours (ignored for the last block)
- price: string; // major units, e.g. "2.00"
-}
-// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the
-// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md.
-interface StepForm {
- hours: string; // inclusive upper bound of this tier, in hours (e.g. "3")
- total: string; // TOTAL major units for a stay within this tier (e.g. "5.00")
-}
-// A pricing body the form edits: a flat rate, a marginal block ladder, or a stepped
-// (up-to) total-by-duration table.
-interface PricingForm {
- mode: "ladder" | "flat" | "stepped";
- flat: string; // major units (used when mode==="flat")
- blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
- steps: StepForm[]; // up-to tiers (used when mode==="stepped")
- dailyCap: string; // "" = no cap (ladder only)
-}
-// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
-interface TierForm {
- name: string;
- priority: string;
- category: string; // "" = applies to all categories
- dow: number[]; // selected days 0..6; empty = every day
- fromHour: string; // "" = all day
- toHour: string;
- dateFrom: string; // "" = unbounded
- dateTo: string;
- pricing: PricingForm;
-}
-interface FormState {
- currency: string;
- gracePeriodEntryMin: string;
- incrementMin: string;
- lostTicket: string;
- gracePeriodExitMin: string;
- // The default (always-active) card — its own flat/ladder body + daily cap.
- base: PricingForm;
- // Optional time/category tiers. Empty ⇒ a bare V1 structure is published.
- tiers: TierForm[];
-}
-
-const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
-const toMajor = (minor: number): string => (minor / 100).toFixed(2);
-
-function emptySteps(): StepForm[] {
- return [
- { hours: "1", total: "2.00" },
- { hours: "3", total: "5.00" },
- ];
-}
-function emptyLadder(): PricingForm {
- return {
- mode: "ladder",
- flat: "0.00",
- dailyCap: "",
- blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
- steps: emptySteps(),
- };
-}
-function emptyTier(): TierForm {
- return {
- name: "",
- priority: "10",
- category: "",
- dow: [],
- fromHour: "",
- toHour: "",
- dateFrom: "",
- dateTo: "",
- pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] },
- };
-}
-
-function emptyForm(): FormState {
- return {
- currency: "EUR",
- gracePeriodEntryMin: "15",
- incrementMin: "60",
- lostTicket: "20.00",
- gracePeriodExitMin: "15",
- base: emptyLadder(),
- tiers: [],
- };
-}
-
-// Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band
-// hours the form edits. Open-ended last band has no hours. Legacy bounded tails still
-// load (shown as their own band).
-function blocksToForm(blocks: TariffBlock[]): BlockForm[] {
- let prev = 0;
- return blocks.map((b) => {
- if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) };
- const hours = (b.uptoMin - prev) / 60;
- prev = b.uptoMin;
- return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) };
- });
-}
-
-// A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits.
-function stepsToForm(steps: TariffStep[]): StepForm[] {
- return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) }));
-}
-
-// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, or stepped).
-function pricingFromCard(c: {
- flatMinor?: number;
- blocks?: TariffBlock[];
- steps?: TariffStep[];
- dailyCapMinor?: number | null;
-}): PricingForm {
- if (c.steps != null && c.steps.length > 0) {
- return { mode: "stepped", flat: "0.00", dailyCap: "", blocks: emptyLadder().blocks, steps: stepsToForm(c.steps) };
- }
- if (c.flatMinor != null) {
- return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks, steps: emptySteps() };
- }
- return {
- mode: "ladder",
- flat: "0.00",
- dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
- blocks: blocksToForm(c.blocks ?? []),
- steps: emptySteps(),
- };
-}
-
-function tierFromCard(c: TariffCard): TierForm {
- const w = c.window ?? {};
- return {
- name: c.name,
- priority: String(c.priority),
- category: c.category ?? "",
- dow: w.dow ? [...w.dow] : [],
- fromHour: w.fromHour ?? "",
- toHour: w.toHour ?? "",
- dateFrom: w.dateFrom ?? "",
- dateTo: w.dateTo ?? "",
- pricing: pricingFromCard(c),
- };
-}
-
-function formFromActive(s: TariffState): FormState {
- const v = s.active;
- if (!v) return emptyForm();
- const st = v.structure;
- const common = {
- currency: v.currency,
- gracePeriodEntryMin: String(st.gracePeriodEntryMin),
- incrementMin: String(st.incrementMin),
- lostTicket: toMajor(st.lostTicketMinor),
- gracePeriodExitMin: String(st.gracePeriodExitMin),
- };
- if (isTariffV2(st)) {
- return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) };
- }
- // V1: the bare ladder becomes the default card body; no tiers.
- return { ...common, base: pricingFromCard(st), tiers: [] };
-}
-
-// Build a tariff card's pricing body (flat XOR ladder XOR stepped) from a PricingForm.
-function pricingToCardBody(p: PricingForm): Pick {
- if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
- if (p.mode === "stepped") {
- // Each row's `hours` IS the inclusive threshold (the matrix "up to N hours").
- const steps: TariffStep[] = p.steps.map((s) => ({
- uptoMin: Math.round(Number(s.hours || "0") * 60),
- totalMinor: toMinor(s.total),
- }));
- return { steps };
- }
- // Accumulate each band's hours into cumulative uptoMin (min); last band open-ended.
- const last = p.blocks.length - 1;
- let cum = 0;
- const blocks: TariffBlock[] = p.blocks.map((b, i) => {
- if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
- cum += Math.round(Number(b.hours || "0") * 60);
- return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) };
- });
- return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) };
-}
-
-function tierToCard(tr: TierForm): TariffCard {
- const window: TariffCard["window"] = {};
- if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b);
- if (tr.fromHour && tr.toHour) {
- window.fromHour = tr.fromHour;
- window.toHour = tr.toHour;
- }
- if (tr.dateFrom) window.dateFrom = tr.dateFrom;
- if (tr.dateTo) window.dateTo = tr.dateTo;
- const card: TariffCard = {
- name: tr.name.trim() || "tier",
- priority: Math.round(Number(tr.priority || "0")),
- ...pricingToCardBody(tr.pricing),
- };
- if (tr.category.trim()) card.category = tr.category.trim();
- if (Object.keys(window).length > 0) card.window = window;
- return card;
-}
-
-function toStructure(f: FormState): TariffStructure {
- const common = {
- gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
- incrementMin: Math.round(Number(f.incrementMin)),
- lostTicketMinor: toMinor(f.lostTicket),
- gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
- overstay: "reprice" as const,
- };
- const baseBody = pricingToCardBody(f.base);
-
- // NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants
- // tiers gets exactly today's shape; the server leaves it untouched).
- if (f.tiers.length === 0) {
- if (f.base.mode === "stepped") {
- // A stepped V1: the up-to table replaces the ladder (blocks empty, no cap).
- return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null };
- }
- if (f.base.mode === "flat") {
- // A flat V1: a single open-ended block at the flat rate (V1 has no flat field).
- return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null };
- }
- return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null };
- }
-
- // Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here).
- return {
- ...common,
- version: 2,
- tz: "",
- defaultCard: { name: "default", priority: 0, ...baseBody },
- windowedCards: f.tiers.map(tierToCard),
- };
-}
+// sessions reprice correctly. The form machinery is shared with the Tariff Lab's
+// draft modal — see TariffEditorForm.tsx. To experiment without publishing, use the
+// lab (a draft only becomes real through this same publish path). See
+// wiki/concepts/tariff.md.
export function TariffComposer() {
const { t } = useTranslation();
const [state, setState] = useState(null);
const [form, setForm] = useState(emptyForm);
+ // Optional label for the version about to be published. Deliberately NOT prefilled
+ // from the active version — a tweaked card republished under last season's name
+ // would mislabel the history.
+ const [versionName, setVersionName] = useState("");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
@@ -274,70 +30,18 @@ export function TariffComposer() {
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
}, []);
- function set(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) {
- 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) {
- 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) {
- setForm((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
- }
- function addTier() {
- setForm((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] }));
- }
- function removeTier(i: number) {
- setForm((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
- }
- function toggleDow(i: number, d: number) {
- setForm((f) => ({
- ...f,
- tiers: f.tiers.map((tr, j) =>
- j === i ? { ...tr, dow: tr.dow.includes(d) ? tr.dow.filter((x) => x !== d) : [...tr.dow, d] } : tr,
- ),
- }));
- }
-
async function publish() {
setSaving(true);
setMsg(null);
try {
- await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
+ await publishTariffVersion({
+ currency: form.currency.trim().toUpperCase(),
+ structure: toStructure(form),
+ ...(versionName.trim() ? { name: versionName.trim() } : {}),
+ });
const fresh = await fetchTariff();
setState(fresh);
+ setVersionName("");
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
} catch (e) {
const text =
@@ -359,6 +63,7 @@ export function TariffComposer() {
+
- {/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
- wants tiers just edits this and publishes a bare V1 structure. */}
-
-
- {/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
- 0}>
- {t("tariff.tiersAdvanced")}
-
{t("tariff.tiersHint")}
- {/* 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 && (
-
+
+ {/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
+ wants tiers just edits this and publishes a bare V1 structure. */}
+
+
+ {/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
+ 0}>
+ {t("tariff.tiersAdvanced")}
+
{t("tariff.tiersHint")}
+ {/* 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 && (
+